prism_q/backend/density_matrix.rs
1//! Exact density-matrix backend.
2//!
3//! Stores the full density operator `rho` for `n` qubits as a `2^(2n)` amplitude
4//! buffer laid out row-major: buffer index `(r << n) | c` holds `<r|rho|c>`. That
5//! layout is isomorphic to a `2n`-qubit statevector whose high `n` qubits index
6//! the ket (row `r`) and whose low `n` qubits index the bra (column `c`). Gate
7//! application therefore reuses the validated statevector kernels directly: a
8//! unitary `U` on the ket register yields the left product `U rho`, and the same
9//! `U` applied to the bra register on a conjugated buffer yields the right product
10//! `rho U^dagger`, giving `U rho U^dagger` with no gate math of its own.
11//!
12//! # Memory layout
13//!
14//! Memory is `16 * 4^n` bytes (`4^n` `Complex64` entries), so the practical
15//! ceiling is about 14 qubits on a 16 GiB host and 15 on a 32 GiB host. With a
16//! device attached through [`DensityMatrixBackend::with_gpu`] the buffer lives in
17//! VRAM instead, where an 11 GiB card holds 13 qubits (1 GiB at 13, 4 GiB at 14
18//! plus scratch), and every sweep runs as a kernel over the embedded buffer. This
19//! backend is explicit-dispatch only and is never chosen by `Auto`.
20//!
21//! # Gate support
22//!
23//! Supported: exact unitary evolution, basis-state probabilities, the one-qubit
24//! reduced density matrix, projective measurement with stochastic collapse, reset,
25//! classically-conditioned gates, exact one-qubit Kraus channels
26//! (`apply_1q_kraus`), exact two-qubit Kraus channels (`apply_2q_kraus`, with
27//! `apply_2q_depolarizing` taking the twirled closed form instead), and exact
28//! `Tr(rho P)` expectation (`expectations_pauli`, which backs
29//! [`Backend::pauli_expectations`]). Fused gates are accepted, so `sim` fuses for
30//! this backend, and at the `2n` width its buffer actually costs rather than at
31//! the circuit width. `QftBlock` carries qubit indices outside the instruction
32//! targets and is remapped onto the ket register before the left product; the
33//! tiled shapes (`MultiFused`, `Multi2q`) apply their constituent gates one at a
34//! time instead. See [`Backend::supports_fused_gates`] for the ordering contract
35//! that requires it. Diagonal gates do not take the two-product route at all:
36//! their two factors combine into one table, so `Rzz` and the diagonal batches
37//! (`BatchPhase`, `BatchRzz`, `DiagonalBatch`) each sweep the buffer once.
38//!
39//! # When to prefer this backend
40//!
41//! - Exact noise-channel evolution: one run yields the exact mixed state,
42//! where trajectory averaging converges as `1/sqrt(shots)`. Selecting it
43//! with a noise model attached routes every `Simulate` terminal except the two
44//! gradient terminals to that one evolution.
45//! - Mixed-state diagnostics: purity and exact `Tr(rho P)` observables.
46//!
47//! # When NOT to use this backend
48//!
49//! - Pure-state circuits; a statevector covers twice the qubits in the same
50//! memory.
51//! - Qubit counts past the `4^n` ceiling; trajectory sampling on a pure-state
52//! backend scales further.
53//! - Noisy circuits with mid-circuit measurement or classical conditioning.
54//! The mixture holds every branch at once, so per-shot feedback cannot be
55//! replayed from it and the noisy terminals reject those shapes.
56//! - Adjoint gradients under noise. The adjoint backpropagates against a pure
57//! state and a channel has no reverse evolution to walk; the parameter-shift
58//! terminal serves the noisy gradient from the mixture instead.
59
60use std::borrow::Cow;
61#[cfg(feature = "gpu")]
62use std::sync::Arc;
63
64use num_complex::Complex64;
65use rand::{RngExt, SeedableRng};
66use rand_chacha::ChaCha8Rng;
67use smallvec::SmallVec;
68
69use crate::backend::simd;
70use crate::backend::statevector::{StatevectorBackend, insert_zero_bit, kernels};
71use crate::backend::{Backend, NORM_CLAMP_MIN, reduced_density, schmidt};
72use crate::circuit::{ClassicalCondition, Instruction};
73use crate::error::Result;
74use crate::gates::{DiagEntry, Gate, McuData, diag_entries_phase, mat_mul_4x4};
75#[cfg(feature = "gpu")]
76use crate::gpu::{GpuContext, GpuState, kernels::density as dk};
77use crate::sim::i_pow;
78use crate::sim::unified_pauli::PauliTerm;
79
80/// Compile a one-qubit Kraus set into the 4x4 superoperator acting on the
81/// `(row-bit, col-bit)` block of `rho`, where block index `i = 2*a + b` orders
82/// `(row-bit a, col-bit b)`:
83/// `S[2a+b][2a'+b'] = sum_k K_k[a][a'] * conj(K_k[b][b'])`.
84///
85/// A single-element set `[U]` gives the unitary sandwich `U rho U^dagger`.
86fn block_superoperator(kraus: &[[[Complex64; 2]; 2]]) -> [[Complex64; 4]; 4] {
87 let mut s = [[Complex64::new(0.0, 0.0); 4]; 4];
88 for k in kraus {
89 for a in 0..2 {
90 for b in 0..2 {
91 for ap in 0..2 {
92 for bp in 0..2 {
93 s[2 * a + b][2 * ap + bp] += k[a][ap] * k[b][bp].conj();
94 }
95 }
96 }
97 }
98 }
99 s
100}
101
102/// Collapse one tile of `rho` onto the `outcome` subspace of the qubit whose
103/// row and column bits in the buffer index are `rmask` and `cmask`, scaling the
104/// survivors by `scale`. `base` is the tile's buffer index, and the tile must
105/// not straddle `rmask`, so one test fixes its row class: a tile on the
106/// eliminated row is a contiguous zero fill, and only the surviving row pays a
107/// per-entry column test.
108fn project_tile(
109 tile: &mut [Complex64],
110 base: usize,
111 rmask: usize,
112 cmask: usize,
113 outcome: bool,
114 scale: Complex64,
115) {
116 if ((base & rmask) != 0) != outcome {
117 simd::zero_slice(tile);
118 return;
119 }
120 let zero = Complex64::new(0.0, 0.0);
121 for (j, amp) in tile.iter_mut().enumerate() {
122 *amp = if (((base + j) & cmask) != 0) == outcome {
123 *amp * scale
124 } else {
125 zero
126 };
127 }
128}
129
130/// Fold the row-set half of one `2 * rmask` block of `rho` onto the row-clear
131/// half, then clear it. `r0` and `r1` are the two halves, or matching sub-tiles
132/// of them, and `cmask` is the qubit's column bit.
133fn reset_fold_pair(r0: &mut [Complex64], r1: &mut [Complex64], cmask: usize) {
134 let zero = Complex64::new(0.0, 0.0);
135 for (j, amp) in r0.iter_mut().enumerate() {
136 *amp = if j & cmask == 0 {
137 *amp + r1[j | cmask]
138 } else {
139 zero
140 };
141 }
142 simd::zero_slice(r1);
143}
144
145/// Tile size for a parallel sweep over `rho` whose body reads the qubit's row
146/// class from the tile's own base index. Both `rmask` and `2 * cmask` are powers
147/// of two and `rmask >= 2 * cmask`, so the result is a multiple of the column run
148/// that divides `rmask`: a tile can never straddle the row bit.
149#[cfg(feature = "parallel")]
150fn row_aligned_tile(cmask: usize, rmask: usize) -> usize {
151 (cmask << 1).max(crate::backend::MIN_PAR_ELEMS).min(rmask)
152}
153
154/// Base buffer index of block `m`, expanding the four block bit positions out
155/// of the compacted index. `positions` must be ascending.
156#[inline(always)]
157fn block_base(m: usize, positions: &[usize; 4]) -> usize {
158 let mut base = m;
159 for &pos in positions {
160 base = insert_zero_bit(base, pos);
161 }
162 base
163}
164
165/// Index into the 16-entry superoperator diagonal for the amplitude at buffer
166/// index `idx`: `4 * tr + tc` with `tr` read from the ket bits and `tc` from
167/// the bra bits of `(q0, q1)`, matching the flat order in
168/// [`DensityMatrixBackend::block_layout`].
169#[inline(always)]
170fn diag_slot(idx: usize, q0: usize, q1: usize, n: usize) -> usize {
171 ((idx >> (q0 + n)) & 1) << 3
172 | ((idx >> (q1 + n)) & 1) << 2
173 | ((idx >> q0) & 1) << 1
174 | ((idx >> q1) & 1)
175}
176
177fn conjugate_2x2(m: &[[Complex64; 2]; 2]) -> [[Complex64; 2]; 2] {
178 [
179 [m[0][0].conj(), m[0][1].conj()],
180 [m[1][0].conj(), m[1][1].conj()],
181 ]
182}
183
184fn conjugate_4x4(m: &[[Complex64; 4]; 4]) -> [[Complex64; 4]; 4] {
185 let mut out = *m;
186 for row in &mut out {
187 for entry in row {
188 *entry = entry.conj();
189 }
190 }
191 out
192}
193
194/// The gate's 2x2 matrix, or `None` for anything else. `Gate::num_qubits` is
195/// not a usable test here: it reports 1 for a `BatchPhase` with no phases, a
196/// single-qubit `DiagonalBatch`, and `QftBlock { num: 1 }`, none of which
197/// `matrix_2x2` accepts.
198fn matrix_1q(gate: &Gate) -> Option<[[Complex64; 2]; 2]> {
199 match gate {
200 Gate::Id
201 | Gate::X
202 | Gate::Y
203 | Gate::Z
204 | Gate::H
205 | Gate::S
206 | Gate::Sdg
207 | Gate::T
208 | Gate::Tdg
209 | Gate::SX
210 | Gate::SXdg
211 | Gate::Rx(_)
212 | Gate::Ry(_)
213 | Gate::Rz(_)
214 | Gate::P(_)
215 | Gate::Fused(_) => Some(gate.matrix_2x2()),
216 _ => None,
217 }
218}
219
220/// `conj(gate)` as a native gate variant, which is what the bra register of
221/// `rho -> U rho U^dagger` needs. `Cx`, `Cz`, and `Swap` are real, so they are
222/// their own conjugate. `None` means the variant has no native conjugate form
223/// and the caller falls back to conjugating the buffer around the gate.
224fn conjugate_gate(gate: &Gate) -> Option<Gate> {
225 match gate {
226 Gate::Cx | Gate::Cz | Gate::Swap => Some(gate.clone()),
227 Gate::Rzz(theta) => Some(Gate::Rzz(-*theta)),
228 Gate::Fused2q(mat) => Some(Gate::Fused2q(Box::new(conjugate_4x4(mat)))),
229 Gate::Cu(mat) => Some(Gate::Cu(Box::new(conjugate_2x2(mat)))),
230 Gate::Mcu(data) => Some(Gate::Mcu(Box::new(McuData {
231 mat: conjugate_2x2(&data.mat),
232 num_controls: data.num_controls,
233 }))),
234 _ => None,
235 }
236}
237
238/// The gate with every qubit index stored inside its payload shifted onto the
239/// ket register. Offsetting the instruction targets is not enough for
240/// `QftBlock`, whose whole range lives in the variant. Borrows the gate when it
241/// carries no such index.
242///
243/// `MultiFused` and `Multi2q` are deliberately absent: shifting a `Multi2q`
244/// payload reorders it, so both apply their constituents directly. The diagonal
245/// batches are absent for a different reason, that
246/// [`DensityMatrixBackend::apply_diagonal_sandwich`] takes them before the
247/// two-product route is reached.
248/// The batch payload as a flat [`DiagEntry`] list, or `None` for a gate that is
249/// not a diagonal batch. `BatchPhase` keeps its shared control in the
250/// instruction targets rather than in the payload, so it arrives separately.
251fn diagonal_batch_entries(gate: &Gate, targets: &[usize]) -> Option<Vec<DiagEntry>> {
252 match gate {
253 Gate::BatchPhase(data) => Some(
254 data.phases
255 .iter()
256 .map(|&(target, phase)| DiagEntry::Phase2q {
257 q0: targets[0],
258 q1: target,
259 phase,
260 })
261 .collect(),
262 ),
263 Gate::BatchRzz(data) => Some(
264 data.edges
265 .iter()
266 .map(|&(q0, q1, theta)| DiagEntry::Parity2q {
267 q0,
268 q1,
269 same: Complex64::from_polar(1.0, -theta / 2.0),
270 diff: Complex64::from_polar(1.0, theta / 2.0),
271 })
272 .collect(),
273 ),
274 Gate::DiagonalBatch(data) => Some(data.entries.clone()),
275 _ => None,
276 }
277}
278
279fn ket_register_gate(gate: &Gate, n: usize) -> Cow<'_, Gate> {
280 match gate {
281 Gate::QftBlock { start, num } => Cow::Owned(Gate::QftBlock {
282 start: start + n as u8,
283 num: *num,
284 }),
285 _ => Cow::Borrowed(gate),
286 }
287}
288
289/// Reject a mixture the device cannot hold before anything is allocated: the
290/// `4^n` buffer at 16 bytes per entry plus the `2^n` diagonal scratch. A
291/// context that cannot report free memory falls through to the allocation,
292/// whose own error stays authoritative.
293#[cfg(feature = "gpu")]
294fn check_device_budget(context: &GpuContext, num_qubits: usize) -> Result<()> {
295 if 2 * num_qubits >= usize::BITS as usize - 4 {
296 return Ok(());
297 }
298 let Ok(free) = context.vram_available() else {
299 return Ok(());
300 };
301 let needed = (1usize << (2 * num_qubits)) * 16 + (1usize << num_qubits) * 8;
302 if needed > free {
303 return Err(crate::error::PrismError::IncompatibleBackend {
304 backend: "density_matrix-gpu".to_string(),
305 reason: format!(
306 "circuit has {num_qubits} qubits needing {} MiB of device memory for the \
307 4^n mixture, exceeding the {} MiB free on the GPU",
308 needed >> 20,
309 free >> 20
310 ),
311 });
312 }
313 Ok(())
314}
315
316/// The channel entry points are infallible by signature, so a device launch
317/// that fails under one has nowhere to report and stops the run instead.
318#[cfg(feature = "gpu")]
319fn launched<T>(result: Result<T>) -> T {
320 result.unwrap_or_else(|e| panic!("density-matrix device kernel failed: {e}"))
321}
322
323/// Exact density-matrix simulator. See the module docs for the state layout.
324pub struct DensityMatrixBackend {
325 num_qubits: usize,
326 classical_bits: Vec<bool>,
327 rng: ChaCha8Rng,
328 sv: StatevectorBackend,
329 #[cfg(feature = "gpu")]
330 gpu_context: Option<Arc<GpuContext>>,
331}
332
333impl DensityMatrixBackend {
334 pub fn new(seed: u64) -> Self {
335 Self {
336 num_qubits: 0,
337 classical_bits: Vec::new(),
338 rng: ChaCha8Rng::seed_from_u64(seed),
339 sv: StatevectorBackend::new(seed),
340 #[cfg(feature = "gpu")]
341 gpu_context: None,
342 }
343 }
344
345 /// Hold the mixture on the device bound to `context`.
346 ///
347 /// [`Backend::init`] then allocates the `4^n` buffer in device memory after a
348 /// budget check against the free VRAM, and every sweep runs as a kernel. A
349 /// device that cannot hold the state is an error at `init`, never a host
350 /// fallback. The channel entry points keep their infallible signatures, so
351 /// a kernel launch that fails under one of them panics.
352 #[cfg(feature = "gpu")]
353 pub fn with_gpu(mut self, context: Arc<GpuContext>) -> Self {
354 self.gpu_context = Some(context.clone());
355 self.sv = self.sv.with_gpu(context);
356 self
357 }
358
359 /// The device state with its context, when the mixture is resident there.
360 #[cfg(feature = "gpu")]
361 fn device(&mut self) -> Option<(Arc<GpuContext>, &mut GpuState)> {
362 let gpu = self.sv.gpu_state_mut()?;
363 let ctx = gpu.context().clone();
364 Some((ctx, gpu))
365 }
366
367 /// The full `4^n` buffer, row-major as the module docs lay it out. Reads
368 /// back from the device when the mixture is resident there.
369 pub fn density_matrix(&self) -> Result<Vec<Complex64>> {
370 self.sv.export_statevector()
371 }
372
373 /// Purity `Tr(rho^2)`, equal to `1` for a pure state and less otherwise.
374 pub fn purity(&self) -> f64 {
375 #[cfg(feature = "gpu")]
376 if let Some(gpu) = self.sv.gpu_state() {
377 return launched(dk::norm_sqr(gpu.context(), gpu, self.num_qubits));
378 }
379 crate::backend::state_norm_sqr(&self.sv.state)
380 }
381
382 #[inline]
383 fn dim(&self) -> usize {
384 1usize << self.num_qubits
385 }
386
387 /// `rho_A[t * dim + t'] = sum_e rho[idx(t, e)][idx(t', e)]` over the
388 /// `2^(n - k)` traced indices `e`, `buffer` being the `4^n` mixture in the
389 /// module's layout. Once the buffer is the size of a statevector past the
390 /// parallel threshold the work is split either as stripes of result rows,
391 /// which repeat only the `k` bit insertions of each traced index, or as a
392 /// fold over the traced index whose jobs each zero and merge a
393 /// `dim * dim` accumulator; the stripe wins once the insertions cost less
394 /// than that.
395 fn partial_trace(&self, buffer: &[Complex64], subsystem: &[usize]) -> Vec<Complex64> {
396 let d = self.dim();
397 let k = subsystem.len();
398 let dim = 1usize << k;
399 let offsets = reduced_density::row_offsets(subsystem);
400 let mut ascending = subsystem.to_vec();
401 ascending.sort_unstable();
402 let groups = d >> k;
403
404 let accumulate_rows = |rows: &mut [Complex64], first: usize, base: usize| {
405 for (r, out) in rows.chunks_exact_mut(dim).enumerate() {
406 let row = (base | offsets[first + r]) * d + base;
407 for (entry, &off) in out.iter_mut().zip(&offsets) {
408 *entry += buffer[row | off];
409 }
410 }
411 };
412 let zero = Complex64::new(0.0, 0.0);
413 #[cfg(feature = "parallel")]
414 if self.sv.num_qubits >= crate::backend::PARALLEL_THRESHOLD_QUBITS {
415 use rayon::prelude::*;
416 if groups * k < 2 * dim * dim {
417 let rows = reduced_density::stripe_rows(dim);
418 let mut rho = vec![zero; dim * dim];
419 rho.par_chunks_mut(rows * dim)
420 .enumerate()
421 .for_each(|(stripe, out)| {
422 for e in 0..groups {
423 let base = reduced_density::traced_base(e, &ascending);
424 accumulate_rows(out, stripe * rows, base);
425 }
426 });
427 return rho;
428 }
429 let fresh = || vec![zero; dim * dim];
430 let add = |mut a: Vec<Complex64>, b: Vec<Complex64>| {
431 for (x, y) in a.iter_mut().zip(&b) {
432 *x += y;
433 }
434 a
435 };
436 return (0..groups)
437 .into_par_iter()
438 .with_min_len(reduced_density::fold_min_len(groups, dim * dim))
439 .fold(fresh, |mut acc, e| {
440 accumulate_rows(&mut acc, 0, reduced_density::traced_base(e, &ascending));
441 acc
442 })
443 .reduce(fresh, add);
444 }
445 let mut rho = vec![zero; dim * dim];
446 for e in 0..groups {
447 accumulate_rows(&mut rho, 0, reduced_density::traced_base(e, &ascending));
448 }
449 rho
450 }
451
452 fn conjugate_buffer(&mut self) -> Result<()> {
453 #[cfg(feature = "gpu")]
454 {
455 let n = self.num_qubits;
456 if let Some((ctx, gpu)) = self.device() {
457 return dk::conjugate(&ctx, gpu, n);
458 }
459 }
460 #[cfg(feature = "parallel")]
461 {
462 use rayon::prelude::*;
463 if self.sv.state.len() >= (1 << crate::backend::PARALLEL_THRESHOLD_QUBITS) {
464 self.sv
465 .state
466 .par_iter_mut()
467 .for_each(|amp| *amp = amp.conj());
468 return Ok(());
469 }
470 }
471 for amp in self.sv.state.iter_mut() {
472 *amp = amp.conj();
473 }
474 Ok(())
475 }
476
477 /// A two-qubit matrix on the embedded buffer, on whichever side holds it.
478 fn fused_2q(&mut self, q0: usize, q1: usize, mat: &[[Complex64; 4]; 4]) -> Result<()> {
479 #[cfg(feature = "gpu")]
480 if let Some((ctx, gpu)) = self.device() {
481 return crate::gpu::kernels::dense::launch_apply_fused_2q(&ctx, gpu, q0, q1, mat);
482 }
483 self.sv.apply_fused_2q(q0, q1, mat);
484 Ok(())
485 }
486
487 /// Apply a compiled one-qubit block superoperator in a single buffer pass.
488 ///
489 /// The `(row-bit, col-bit)` block of `qubit` is the two-qubit subspace
490 /// `(qubit + n, qubit)` of the embedded `2n`-qubit statevector, so the
491 /// statevector two-qubit kernel applies `S` directly. That kernel indexes
492 /// its matrix as `2 * bit(q0) + bit(q1)`, matching the block index `2a + b`
493 /// once the row bit is passed as `q0`.
494 fn apply_block_superoperator(&mut self, qubit: usize, s: &[[Complex64; 4]; 4]) -> Result<()> {
495 let n = self.num_qubits;
496 self.fused_2q(qubit + n, qubit, s)
497 }
498
499 /// Evolve `rho -> U rho U^dagger` for the unitary `gate` on `targets`.
500 ///
501 /// `U` applies to the ket register (targets and payload indices offset by
502 /// `n`, see [`ket_register_gate`]) for the left product `U rho`, and
503 /// `conj(U)` to the bra register (indices unchanged) for the right product
504 /// `rho U^dagger`. Variants with no native conjugate form fall back to
505 /// conjugating the whole buffer around the gate, which costs two extra
506 /// passes.
507 ///
508 /// One-qubit gates take [`DensityMatrixBackend::apply_1q_sandwich`], `Rzz`
509 /// takes [`DensityMatrixBackend::apply_rzz_sandwich`], and the diagonal
510 /// batches take [`DensityMatrixBackend::apply_diagonal_sandwich`]. All
511 /// three fold both products into one pass rather than taking a conjugate
512 /// form here.
513 ///
514 /// `Multi2q` carries a gate list that the statevector kernel partitions by
515 /// cache tier and runs one tier at a time, which preserves application
516 /// order only while the whole list sits in one tier. Fusion guarantees that
517 /// against the circuit's own qubit indices, and the `+n` shift onto the ket
518 /// register moves gates across the tier bounds, so the ket half applies its
519 /// constituents one at a time. The bra half keeps the circuit's indices, so
520 /// it batches through the tiled pass whenever
521 /// [`kernels::multi_2q_single_tier`] holds, and falls back to
522 /// per-constituent application otherwise. `MultiFused` entries
523 /// are one per qubit and commute, so its list is order independent; it takes
524 /// the same treatment because the one-qubit sandwich is cheaper than the
525 /// tiled pass here.
526 fn apply_unitary(&mut self, gate: &Gate, targets: &[usize]) -> Result<()> {
527 let n = self.num_qubits;
528
529 if let Some(mat) = matrix_1q(gate) {
530 return self.apply_1q_sandwich(targets[0], &mat);
531 }
532
533 match gate {
534 Gate::MultiFused(data) => {
535 for (qubit, mat) in data.gates.iter() {
536 self.apply_1q_sandwich(*qubit, mat)?;
537 }
538 return Ok(());
539 }
540 Gate::Rzz(theta) => {
541 return self.apply_rzz_sandwich(targets[0], targets[1], *theta);
542 }
543 Gate::Multi2q(data) => {
544 for &(q0, q1, ref mat) in data.gates.iter() {
545 self.fused_2q(q0 + n, q1 + n, mat)?;
546 }
547 if !self.sv.is_gpu_resident() && kernels::multi_2q_single_tier(&data.gates) {
548 let conjugated: Vec<(usize, usize, [[Complex64; 4]; 4])> = data
549 .gates
550 .iter()
551 .map(|&(q0, q1, ref mat)| (q0, q1, conjugate_4x4(mat)))
552 .collect();
553 self.sv.apply_multi_2q(&conjugated);
554 } else {
555 for &(q0, q1, ref mat) in data.gates.iter() {
556 self.fused_2q(q0, q1, &conjugate_4x4(mat))?;
557 }
558 }
559 return Ok(());
560 }
561 _ => {}
562 }
563
564 if let Some(entries) = diagonal_batch_entries(gate, targets) {
565 return self.apply_diagonal_sandwich(&entries);
566 }
567
568 let ket_targets: SmallVec<[usize; 4]> = targets.iter().map(|&t| t + n).collect();
569 self.sv.apply(&Instruction::Gate {
570 gate: ket_register_gate(gate, n).into_owned(),
571 targets: ket_targets,
572 })?;
573
574 let bra_targets: SmallVec<[usize; 4]> = targets.iter().copied().collect();
575 if let Some(conjugate) = conjugate_gate(gate) {
576 return self.sv.apply(&Instruction::Gate {
577 gate: conjugate,
578 targets: bra_targets,
579 });
580 }
581
582 self.conjugate_buffer()?;
583 self.sv.apply(&Instruction::Gate {
584 gate: gate.clone(),
585 targets: bra_targets,
586 })?;
587 self.conjugate_buffer()
588 }
589
590 /// Evolve `rho -> U rho U^dagger` for a one-qubit `U`.
591 ///
592 /// Above the parallel threshold, and always on the device, the two products
593 /// compile to one block superoperator and sweep the buffer once; below it
594 /// the superoperator's dense 4x4 block loses to two cheap register passes.
595 fn apply_1q_sandwich(&mut self, qubit: usize, matrix: &[[Complex64; 2]; 2]) -> Result<()> {
596 let n = self.num_qubits;
597 if self.sv.is_gpu_resident() || 2 * n >= crate::backend::PARALLEL_THRESHOLD_QUBITS {
598 let s = block_superoperator(&[*matrix]);
599 return self.apply_block_superoperator(qubit, &s);
600 }
601 self.sv.apply_1q_matrix(qubit + n, matrix)?;
602 self.sv.apply_1q_matrix(qubit, &conjugate_2x2(matrix))
603 }
604
605 /// Evolve `rho -> R rho R^dagger` for `R = Rzz(theta)` in one buffer pass.
606 ///
607 /// Both factors are diagonal, so the entry at `(r, c)` picks up
608 /// `phase(p_r) * conj(phase(p_c))` where `p` is the parity of the target
609 /// pair in that register and `phase` is the statevector kernel's
610 /// `[exp(-i theta/2), exp(+i theta/2)]`. Conjugate phases cancel wherever
611 /// the two registers agree on parity, which is half the sixteen entry
612 /// classes, and the rest carry `exp(-i theta)` or its conjugate. The
613 /// generic route's two register passes therefore collapse onto the single
614 /// contiguous pass [`DensityMatrixBackend::kraus_2q_diagonal_sweep`]
615 /// already runs for a diagonal channel.
616 fn apply_rzz_sandwich(&mut self, q0: usize, q1: usize, theta: f64) -> Result<()> {
617 let phase = [
618 Complex64::from_polar(1.0, -theta / 2.0),
619 Complex64::from_polar(1.0, theta / 2.0),
620 ];
621 let mut diag = [Complex64::new(1.0, 0.0); 16];
622 for tr in 0..4usize {
623 for tc in 0..4usize {
624 let pr = ((tr >> 1) ^ tr) & 1;
625 let pc = ((tc >> 1) ^ tc) & 1;
626 diag[4 * tr + tc] = phase[pr] * phase[pc].conj();
627 }
628 }
629 self.kraus_2q_diagonal_sweep(&diag, q0, q1)
630 }
631
632 /// Evolve `rho -> D rho D^dagger` for a diagonal batch `D` in one buffer
633 /// pass.
634 ///
635 /// A diagonal `D` scales `<r|rho|c>` by `f(r) * conj(f(c))`, where `f` is
636 /// the combined phase the payload puts on a basis index. The embedded
637 /// layout splits the buffer index into exactly those two operands, `r` in
638 /// the high `n` bits and `c` in the low `n`, so one table of `2^n` phases
639 /// serves both registers and the sweep reads it twice per amplitude. The
640 /// table costs `2^n` against the `4^n` buffer it saves three passes on: the
641 /// generic route has no conjugate form for these variants and pays two
642 /// register passes plus two buffer conjugations.
643 ///
644 /// Entry order does not matter, all of them being diagonal, so the tier
645 /// reordering the tiled payloads have to avoid cannot arise here.
646 fn apply_diagonal_sandwich(&mut self, entries: &[DiagEntry]) -> Result<()> {
647 let n = self.num_qubits;
648 let d = self.dim();
649 let ket: Vec<Complex64> = (0..d).map(|r| diag_entries_phase(r, entries)).collect();
650
651 #[cfg(feature = "gpu")]
652 if let Some((ctx, gpu)) = self.device() {
653 return dk::diagonal_sandwich(&ctx, gpu, n, &ket);
654 }
655
656 let bra: Vec<Complex64> = ket.iter().map(|f| f.conj()).collect();
657
658 #[cfg(feature = "parallel")]
659 if 2 * n >= crate::backend::PARALLEL_THRESHOLD_QUBITS {
660 use crate::backend::MIN_PAR_ELEMS;
661 use rayon::prelude::*;
662
663 self.sv
664 .state
665 .par_iter_mut()
666 .enumerate()
667 .with_min_len(MIN_PAR_ELEMS)
668 .for_each(|(idx, amp)| {
669 *amp *= ket[idx >> n] * bra[idx & (d - 1)];
670 });
671 return Ok(());
672 }
673
674 for (idx, amp) in self.sv.state.iter_mut().enumerate() {
675 *amp *= ket[idx >> n] * bra[idx & (d - 1)];
676 }
677 Ok(())
678 }
679
680 /// `P(qubit = |1>) = Tr(P_1 rho)`, the sum of the diagonal `rho` entries
681 /// whose row index has `qubit` set.
682 fn prob_one(&self, qubit: usize) -> Result<f64> {
683 let d = self.dim();
684 let bit = 1usize << qubit;
685 #[cfg(feature = "gpu")]
686 if let Some(gpu) = self.sv.gpu_state() {
687 let diag = dk::diagonal(gpu.context(), gpu, self.num_qubits)?;
688 let p1: f64 = diag
689 .iter()
690 .enumerate()
691 .filter(|(r, _)| r & bit != 0)
692 .map(|(_, p)| p)
693 .sum();
694 return Ok(p1.clamp(0.0, 1.0));
695 }
696 let mut p1 = 0.0;
697 for r in 0..d {
698 if r & bit != 0 {
699 p1 += self.sv.state[r * d + r].re;
700 }
701 }
702 Ok(p1.clamp(0.0, 1.0))
703 }
704
705 /// Sample and project qubit `qubit`, recording the outcome in
706 /// `classical_bit`. Collapses `rho -> P_m rho P_m / p_m`, which in the
707 /// embedded layout zeroes every entry whose row or column disagrees with
708 /// the outcome on `qubit` and rescales the survivors to unit trace.
709 fn apply_measure(&mut self, qubit: usize, classical_bit: usize) -> Result<()> {
710 let p1 = self.prob_one(qubit)?;
711 let u: f64 = self.rng.random();
712 let outcome = u < p1;
713 self.classical_bits[classical_bit] = outcome;
714 let p = if outcome { p1 } else { 1.0 - p1 };
715 self.project(qubit, outcome, p)
716 }
717
718 /// Deterministic reset `rho -> |0><0| (x) tr_q rho`: fold the block with
719 /// `qubit` set on both row and column into the block with it clear on both,
720 /// then zero the three sibling entries that still touch `qubit`. The four
721 /// entry classes are contiguous runs of the buffer, so the pass walks
722 /// `2 * rmask` blocks rather than scattering per block base.
723 fn apply_reset(&mut self, qubit: usize) -> Result<()> {
724 let n = self.num_qubits;
725 #[cfg(feature = "gpu")]
726 if let Some((ctx, gpu)) = self.device() {
727 return dk::reset(&ctx, gpu, n, qubit);
728 }
729 let rmask = 1usize << (qubit + n);
730 let cmask = 1usize << qubit;
731 let block_size = rmask << 1;
732
733 #[cfg(feature = "parallel")]
734 if 2 * n >= crate::backend::PARALLEL_THRESHOLD_QUBITS {
735 use crate::backend::chunk_min_len;
736 use rayon::prelude::*;
737
738 if self.sv.state.len() / block_size >= 4 {
739 self.sv
740 .state
741 .par_chunks_mut(block_size)
742 .with_min_len(chunk_min_len(block_size))
743 .for_each(|block| {
744 let (r0, r1) = block.split_at_mut(rmask);
745 reset_fold_pair(r0, r1, cmask);
746 });
747 return Ok(());
748 }
749
750 let tile = row_aligned_tile(cmask, rmask);
751 for block in self.sv.state.chunks_mut(block_size) {
752 let (r0, r1) = block.split_at_mut(rmask);
753 r0.par_chunks_mut(tile)
754 .zip(r1.par_chunks_mut(tile))
755 .for_each(|(t0, t1)| reset_fold_pair(t0, t1, cmask));
756 }
757 return Ok(());
758 }
759
760 for block in self.sv.state.chunks_mut(block_size) {
761 let (r0, r1) = block.split_at_mut(rmask);
762 reset_fold_pair(r0, r1, cmask);
763 }
764 Ok(())
765 }
766
767 /// Project `rho` onto the `outcome` subspace of `qubit` with outcome
768 /// probability `p`, renormalizing the survivors to unit trace.
769 fn project(&mut self, qubit: usize, outcome: bool, p: f64) -> Result<()> {
770 let n = self.num_qubits;
771 let rmask = 1usize << (qubit + n);
772 let cmask = 1usize << qubit;
773 let scale = Complex64::new(1.0 / p.clamp(NORM_CLAMP_MIN, 1.0), 0.0);
774
775 #[cfg(feature = "gpu")]
776 if let Some((ctx, gpu)) = self.device() {
777 return dk::project(&ctx, gpu, n, qubit, outcome, scale.re);
778 }
779
780 #[cfg(feature = "parallel")]
781 if 2 * n >= crate::backend::PARALLEL_THRESHOLD_QUBITS {
782 use crate::backend::chunk_min_len;
783 use rayon::prelude::*;
784
785 let tile = row_aligned_tile(cmask, rmask);
786 self.sv
787 .state
788 .par_chunks_mut(tile)
789 .with_min_len(chunk_min_len(tile))
790 .enumerate()
791 .for_each(|(t, chunk)| {
792 project_tile(chunk, t * tile, rmask, cmask, outcome, scale);
793 });
794 return Ok(());
795 }
796
797 for (t, chunk) in self.sv.state.chunks_mut(rmask).enumerate() {
798 project_tile(chunk, t * rmask, rmask, cmask, outcome, scale);
799 }
800 Ok(())
801 }
802
803 fn apply_conditional(
804 &mut self,
805 condition: &ClassicalCondition,
806 gate: &Gate,
807 targets: &[usize],
808 ) -> Result<()> {
809 if condition.evaluate(&self.classical_bits) {
810 self.apply_unitary(gate, targets)?;
811 }
812 Ok(())
813 }
814
815 /// Apply a general single-qubit channel `rho -> sum_k K_k rho K_k^dagger`
816 /// on `qubit`. The Kraus set is compiled once into a 4x4 block
817 /// superoperator acting on each `(row-bit, col-bit)` block of `rho`, so the
818 /// buffer is swept once with no per-element allocation.
819 pub fn apply_1q_kraus(&mut self, qubit: usize, kraus: &[[[Complex64; 2]; 2]]) {
820 let s = block_superoperator(kraus);
821 self.apply_block_superoperator_infallible(qubit, &s);
822 }
823
824 /// [`DensityMatrixBackend::apply_block_superoperator`] for the entry points
825 /// that carry no error channel: the host path cannot fail and a device
826 /// launch failure stops the run.
827 fn apply_block_superoperator_infallible(&mut self, qubit: usize, s: &[[Complex64; 4]; 4]) {
828 let result = self.apply_block_superoperator(qubit, s);
829 #[cfg(feature = "gpu")]
830 launched(result);
831 #[cfg(not(feature = "gpu"))]
832 result.expect("host block superoperator is infallible");
833 }
834
835 /// Apply `gate` and then every Kraus set in `channels`, all one-qubit maps
836 /// on `qubit`, in one buffer sweep instead of one per map.
837 ///
838 /// Returns false with the buffer untouched when `gate` has no one-qubit
839 /// matrix. Each block superoperator acts on the same `(row-bit, col-bit)`
840 /// 4-vector of `qubit`, so the composition is their matrix product in
841 /// application order and costs 64 complex multiplies per factor, once per
842 /// instruction rather than once per amplitude.
843 pub(crate) fn try_apply_fused_1q_channels(
844 &mut self,
845 gate: &Gate,
846 qubit: usize,
847 channels: &[Vec<[[Complex64; 2]; 2]>],
848 ) -> bool {
849 let Some(mat) = matrix_1q(gate) else {
850 return false;
851 };
852 let mut s = block_superoperator(&[mat]);
853 for kraus in channels {
854 s = mat_mul_4x4(&block_superoperator(kraus), &s);
855 }
856 self.apply_block_superoperator_infallible(qubit, &s);
857 true
858 }
859
860 /// Apply symmetric two-qubit depolarizing on `(q0, q1)`:
861 /// `rho -> (1-p) rho + (p/15) sum_{P != I(x)I} P rho P`, summed over the 15
862 /// non-identity two-qubit Paulis.
863 ///
864 /// The Pauli twirl `sum_P P B P = 4 Tr(B) I4` over all 16 two-qubit Paulis
865 /// holds for any 4x4 `B`, so on each `(q0, q1)` block the map collapses to
866 /// `B -> alpha B + beta Tr(B) I4` with `alpha = 1 - 16p/15` and
867 /// `beta = 4p/15`. That is one real scale per amplitude against the 16
868 /// complex multiply-accumulates the equivalent 16x16 superoperator pays in
869 /// [`DensityMatrixBackend::apply_2q_kraus`].
870 ///
871 /// # Panics
872 ///
873 /// If `q0` and `q1` are equal or outside the register, as
874 /// [`DensityMatrixBackend::apply_2q_kraus`] does.
875 pub fn apply_2q_depolarizing(&mut self, q0: usize, q1: usize, p: f64) {
876 let alpha = 1.0 - 16.0 * p / 15.0;
877 let beta = 4.0 * p / 15.0;
878 let (positions, flats, num_groups) = self.block_layout(q0, q1);
879
880 #[cfg(feature = "gpu")]
881 {
882 let n = self.num_qubits;
883 if let Some((ctx, gpu)) = self.device() {
884 launched(dk::depolarizing_2q(&ctx, gpu, n, q0, q1, alpha, beta));
885 return;
886 }
887 }
888
889 #[cfg(feature = "parallel")]
890 if 2 * self.num_qubits >= crate::backend::PARALLEL_THRESHOLD_QUBITS {
891 use crate::backend::MIN_PAR_ITERS;
892 use crate::backend::statevector::SendPtr;
893 use rayon::prelude::*;
894
895 let ptr = SendPtr(self.sv.state.as_mut_ptr());
896 (0..num_groups)
897 .into_par_iter()
898 .with_min_len(MIN_PAR_ITERS)
899 .for_each(move |m| {
900 let base = block_base(m, &positions);
901 // SAFETY: inserting a zero bit at each of the four block
902 // positions is a bijection from `m` onto the block bases, so
903 // the 16 offsets one iteration touches are disjoint from
904 // every other iteration's. The safe alternative needs the
905 // 16 entries as disjoint sub-slices, which they are not:
906 // they are strided across four independent bit positions.
907 unsafe {
908 let mut trace = Complex64::new(0.0, 0.0);
909 for t in 0..4 {
910 trace += ptr.load(base | flats[5 * t]);
911 }
912 let shift = trace * beta;
913 for (i, &off) in flats.iter().enumerate() {
914 let mut out = ptr.load(base | off) * alpha;
915 if i % 5 == 0 {
916 out += shift;
917 }
918 ptr.store(base | off, out);
919 }
920 }
921 });
922 return;
923 }
924
925 for m in 0..num_groups {
926 let base = block_base(m, &positions);
927 let mut trace = Complex64::new(0.0, 0.0);
928 for t in 0..4 {
929 trace += self.sv.state[base | flats[5 * t]];
930 }
931 let shift = trace * beta;
932 for (i, &off) in flats.iter().enumerate() {
933 let mut out = self.sv.state[base | off] * alpha;
934 if i % 5 == 0 {
935 out += shift;
936 }
937 self.sv.state[base | off] = out;
938 }
939 }
940 }
941
942 /// Block traversal for a two-qubit map on `(q0, q1)`: the four buffer bit
943 /// positions the block spans in ascending order, the 16 offsets from a
944 /// block base indexed `4 * tr + tc`, and the number of blocks. Block index
945 /// `5 * t` is the diagonal entry `tr == tc == t`.
946 ///
947 /// The four positions must be distinct and inside the register, or the
948 /// zero-bit insertion in [`block_base`] stops being a bijection onto the
949 /// block bases and the sweep reads and writes the wrong entries. Both channel
950 /// entry points are public and route through here, so this is their choke
951 /// point. It is not the sweep's:
952 /// [`DensityMatrixBackend::apply_rzz_sandwich`] builds its table and calls
953 /// the sweep directly, where equal targets yield the identity table that the
954 /// two register passes it replaced also produced.
955 fn block_layout(&self, q0: usize, q1: usize) -> ([usize; 4], [usize; 16], usize) {
956 let n = self.num_qubits;
957 assert!(
958 q0 != q1 && q0 < n && q1 < n,
959 "two-qubit channel on ({q0}, {q1}) needs distinct targets inside the {n}-qubit register"
960 );
961 let mut positions = [q0, q1, q0 + n, q1 + n];
962 positions.sort_unstable();
963
964 let mut flats = [0usize; 16];
965 for tr in 0..4 {
966 for tc in 0..4 {
967 flats[4 * tr + tc] = (if tr & 2 != 0 { 1usize << (q0 + n) } else { 0 })
968 | (if tr & 1 != 0 { 1usize << (q1 + n) } else { 0 })
969 | (if tc & 2 != 0 { 1usize << q0 } else { 0 })
970 | (if tc & 1 != 0 { 1usize << q1 } else { 0 });
971 }
972 }
973
974 let d = self.dim();
975 (positions, flats, (d * d) >> 4)
976 }
977
978 /// Apply a general two-qubit channel `rho -> sum_k K_k rho K_k^dagger` on
979 /// `(q0, q1)`. Each operator is indexed `K[t][t']` with
980 /// `t = 2 * bit(q0) + bit(q1)`, matching [`Gate::matrix_4x4`].
981 ///
982 /// The Kraus set is compiled once into a 16x16 block superoperator over the
983 /// four-bit `(q0-row, q1-row, q0-col, q1-col)` block, so the buffer is swept
984 /// once. Block index `4*tr + tc` orders the two-qubit row value `tr` against
985 /// the column value `tc`. An all-diagonal set compiles to a diagonal
986 /// superoperator and is applied as one complex multiply per amplitude in a
987 /// contiguous pass instead of the dense sweep. On hosts with detected AVX2
988 /// and FMA the dense sweep issues each row inner product two complex terms
989 /// per FMA through `simd::PreparedKraus2q`.
990 ///
991 /// # Panics
992 ///
993 /// If `q0` and `q1` are equal or outside the register. A model built through
994 /// [`NoiseModel`](crate::NoiseModel) is rejected before it reaches here.
995 pub fn apply_2q_kraus(&mut self, q0: usize, q1: usize, kraus: &[[[Complex64; 4]; 4]]) {
996 // S[4*tr+tc][4*trp+tcp] = sum_k K_k[tr][trp] * conj(K_k[tc][tcp]).
997 let mut s = [[Complex64::new(0.0, 0.0); 16]; 16];
998 for k in kraus {
999 for tr in 0..4 {
1000 for trp in 0..4 {
1001 let kr = k[tr][trp];
1002 if kr == Complex64::new(0.0, 0.0) {
1003 continue;
1004 }
1005 for tc in 0..4 {
1006 for tcp in 0..4 {
1007 s[4 * tr + tc][4 * trp + tcp] += kr * k[tc][tcp].conj();
1008 }
1009 }
1010 }
1011 }
1012 }
1013
1014 let (positions, flats, num_groups) = self.block_layout(q0, q1);
1015
1016 // Off-diagonal entries of an all-diagonal set only ever accumulate
1017 // `kr * conj(0.0)`, so exact zero is the test.
1018 let zero = Complex64::new(0.0, 0.0);
1019 let diagonal = s
1020 .iter()
1021 .enumerate()
1022 .all(|(r, row)| row.iter().enumerate().all(|(c, &e)| r == c || e == zero));
1023 if diagonal {
1024 let diag = std::array::from_fn(|i| s[i][i]);
1025 let result = self.kraus_2q_diagonal_sweep(&diag, q0, q1);
1026 #[cfg(feature = "gpu")]
1027 launched(result);
1028 #[cfg(not(feature = "gpu"))]
1029 result.expect("host diagonal sweep is infallible");
1030 return;
1031 }
1032
1033 #[cfg(feature = "gpu")]
1034 {
1035 let n = self.num_qubits;
1036 if let Some((ctx, gpu)) = self.device() {
1037 launched(dk::kraus_2q_dense(&ctx, gpu, n, q0, q1, &s));
1038 return;
1039 }
1040 }
1041
1042 #[cfg(target_arch = "x86_64")]
1043 if simd::has_avx2_fma() && simd::kraus_2q_wide_enabled() {
1044 // SAFETY: AVX2 and FMA checked above; AVX2 implies the AVX the
1045 // constructor requires.
1046 let prepared = unsafe { simd::PreparedKraus2q::new(&s) };
1047 self.kraus_2q_sweep_wide(&prepared, &positions, &flats, num_groups);
1048 return;
1049 }
1050
1051 match positions[0] {
1052 0 | 1 => self.kraus_2q_sweep::<1>(&s, &positions, &flats, num_groups),
1053 _ => self.kraus_2q_sweep::<4>(&s, &positions, &flats, num_groups),
1054 }
1055 }
1056
1057 /// [`DensityMatrixBackend::kraus_2q_sweep`] with the row inner products
1058 /// issued through [`simd::PreparedKraus2q`], one block per iteration
1059 /// whatever the qubit pair: the vector axis is the superoperator's 16-wide
1060 /// `j` axis, which does not depend on `min(q0, q1)` the way the block-run
1061 /// width does.
1062 #[cfg(target_arch = "x86_64")]
1063 fn kraus_2q_sweep_wide(
1064 &mut self,
1065 prepared: &simd::PreparedKraus2q,
1066 positions: &[usize; 4],
1067 flats: &[usize; 16],
1068 num_groups: usize,
1069 ) {
1070 #[cfg(feature = "parallel")]
1071 if 2 * self.num_qubits >= crate::backend::PARALLEL_THRESHOLD_QUBITS {
1072 use crate::backend::MIN_PAR_ITERS;
1073 use crate::backend::statevector::SendPtr;
1074 use rayon::prelude::*;
1075
1076 let ptr = SendPtr(self.sv.state.as_mut_ptr());
1077 let positions = *positions;
1078 let flats = *flats;
1079 (0..num_groups)
1080 .into_par_iter()
1081 .with_min_len(MIN_PAR_ITERS)
1082 .for_each(move |m| {
1083 let base = block_base(m, &positions);
1084 // SAFETY: AVX2 and FMA detected. Inserting a zero bit at
1085 // each of the four block positions is a bijection from
1086 // `m` onto the block bases, so every `base | flats[j]`
1087 // stays under `4^n` and the 16 offsets one iteration
1088 // touches are disjoint from every other iteration's.
1089 unsafe { prepared.apply_block_ptr(ptr.as_f64_ptr(), base, &flats) };
1090 });
1091 return;
1092 }
1093
1094 let ptr = self.sv.state.as_mut_ptr() as *mut f64;
1095 for m in 0..num_groups {
1096 let base = block_base(m, positions);
1097 // SAFETY: AVX2 and FMA detected. The block bases are disjoint and
1098 // in bounds as in the parallel arm, on one thread.
1099 unsafe { prepared.apply_block_ptr(ptr, base, flats) };
1100 }
1101 }
1102
1103 /// Apply a diagonal block superoperator in one contiguous pass: the
1104 /// amplitude at `idx` is scaled by `diag[diag_slot(idx, q0, q1, n)]`, one
1105 /// complex multiply against the dense sweep's 16 multiply-accumulates.
1106 fn kraus_2q_diagonal_sweep(
1107 &mut self,
1108 diag: &[Complex64; 16],
1109 q0: usize,
1110 q1: usize,
1111 ) -> Result<()> {
1112 let n = self.num_qubits;
1113
1114 #[cfg(feature = "gpu")]
1115 if let Some((ctx, gpu)) = self.device() {
1116 return dk::kraus_2q_diagonal(&ctx, gpu, n, q0, q1, diag);
1117 }
1118
1119 #[cfg(feature = "parallel")]
1120 if 2 * n >= crate::backend::PARALLEL_THRESHOLD_QUBITS {
1121 use crate::backend::MIN_PAR_ELEMS;
1122 use rayon::prelude::*;
1123
1124 let diag = *diag;
1125 self.sv
1126 .state
1127 .par_iter_mut()
1128 .enumerate()
1129 .with_min_len(MIN_PAR_ELEMS)
1130 .for_each(move |(idx, amp)| {
1131 *amp *= diag[diag_slot(idx, q0, q1, n)];
1132 });
1133 return Ok(());
1134 }
1135
1136 for (idx, amp) in self.sv.state.iter_mut().enumerate() {
1137 *amp *= diag[diag_slot(idx, q0, q1, n)];
1138 }
1139 Ok(())
1140 }
1141
1142 /// Sweep the buffer applying the compiled 16x16 block superoperator `s`,
1143 /// `W` blocks per iteration.
1144 ///
1145 /// `W` must divide `2^positions[0]`. Blocks whose compacted indices form an
1146 /// aligned run of that length have consecutive bases, because
1147 /// [`insert_zero_bit`] preserves every bit below its position and no `flats`
1148 /// entry has a bit below `positions[0]`. Each of the 16 slots is then a
1149 /// contiguous run of `W` amplitudes, which amortizes the base and offset
1150 /// arithmetic over `W` blocks and gives the accumulator chains room to
1151 /// overlap. The arithmetic itself stays scalar: the crate builds at the
1152 /// x86-64 baseline and every wide kernel here takes its width from
1153 /// `#[target_feature]` dispatch in [`crate::backend::simd`], which this does
1154 /// not use.
1155 ///
1156 /// Only `W = 1` and `W = 4` are instantiated. `W = 1` is forced when
1157 /// `min(q0, q1) == 0`, where one block bit is bit 0 and no run exists, and
1158 /// it also serves `min(q0, q1) == 1`: a two-block step measured +0.2% and
1159 /// +1.9% against one, so it earned no arm of its own. Hosts with detected
1160 /// AVX2 and FMA route past this sweep to
1161 /// [`DensityMatrixBackend::kraus_2q_sweep_wide`] unless
1162 /// `PRISM_NO_AVX2_KRAUS` is set.
1163 fn kraus_2q_sweep<const W: usize>(
1164 &mut self,
1165 s: &[[Complex64; 16]; 16],
1166 positions: &[usize; 4],
1167 flats: &[usize; 16],
1168 num_groups: usize,
1169 ) {
1170 let zero = Complex64::new(0.0, 0.0);
1171 let steps = num_groups / W;
1172
1173 #[cfg(feature = "parallel")]
1174 if 2 * self.num_qubits >= crate::backend::PARALLEL_THRESHOLD_QUBITS {
1175 use crate::backend::MIN_PAR_ITERS;
1176 use crate::backend::statevector::SendPtr;
1177 use rayon::prelude::*;
1178
1179 let ptr = SendPtr(self.sv.state.as_mut_ptr());
1180 let s = *s;
1181 let positions = *positions;
1182 let flats = *flats;
1183 (0..steps)
1184 .into_par_iter()
1185 .with_min_len(MIN_PAR_ITERS.div_ceil(W))
1186 .for_each(move |step| {
1187 let base = block_base(step * W, &positions);
1188 let mut v = [[zero; W]; 16];
1189 // SAFETY: inserting a zero bit at each of the four block
1190 // positions is a bijection from the compacted index onto the
1191 // block bases, so the `16 * W` offsets one iteration touches
1192 // are disjoint from every other iteration's. The safe
1193 // alternative needs them as disjoint sub-slices, which they
1194 // are not: the 16 slots are strided across four independent
1195 // bit positions.
1196 unsafe {
1197 for (slot, &off) in v.iter_mut().zip(flats.iter()) {
1198 for (j, lane) in slot.iter_mut().enumerate() {
1199 *lane = ptr.load((base | off) + j);
1200 }
1201 }
1202 for (row, &off) in s.iter().zip(flats.iter()) {
1203 let mut acc = [zero; W];
1204 for (&coeff, slot) in row.iter().zip(v.iter()) {
1205 for (a, &lane) in acc.iter_mut().zip(slot.iter()) {
1206 *a += coeff * lane;
1207 }
1208 }
1209 for (j, &a) in acc.iter().enumerate() {
1210 ptr.store((base | off) + j, a);
1211 }
1212 }
1213 }
1214 });
1215 return;
1216 }
1217
1218 for step in 0..steps {
1219 let base = block_base(step * W, positions);
1220 let mut v = [[zero; W]; 16];
1221 for (k, &off) in flats.iter().enumerate() {
1222 v[k].copy_from_slice(&self.sv.state[(base | off)..(base | off) + W]);
1223 }
1224 for (row, &off) in s.iter().zip(flats.iter()) {
1225 let mut acc = [zero; W];
1226 for (&coeff, slot) in row.iter().zip(v.iter()) {
1227 for (a, &lane) in acc.iter_mut().zip(slot.iter()) {
1228 *a += coeff * lane;
1229 }
1230 }
1231 self.sv.state[(base | off)..(base | off) + W].copy_from_slice(&acc);
1232 }
1233 }
1234 }
1235
1236 /// Exact `Tr(rho P)` for every joint Pauli reduced to `(xmask, zmask, num_y)`,
1237 /// where `P|j> = i^{num_y} * (-1)^{popcount(j & zmask)} * |j ^ xmask>`. The
1238 /// trace collapses to a diagonal-offset sum:
1239 /// `Tr(rho P) = i^{num_y} sum_j (-1)^{popcount(j & zmask)} rho[j][j ^ xmask]`.
1240 ///
1241 /// Each row of the `4^n` buffer is visited once and contributes one entry per
1242 /// observable, so the strided sweep is paid once instead of once per observable.
1243 pub fn expectations_pauli(&self, masks: &[(usize, usize, u32)]) -> Vec<f64> {
1244 let d = self.dim();
1245 #[cfg(feature = "gpu")]
1246 if let Some(gpu) = self.sv.gpu_state() {
1247 let pairs: Vec<(u64, u64)> = masks
1248 .iter()
1249 .map(|&(x, z, _)| (x as u64, z as u64))
1250 .collect();
1251 let sums = launched(dk::pauli_sums(gpu.context(), gpu, self.num_qubits, &pairs));
1252 return sums
1253 .iter()
1254 .zip(masks)
1255 .map(|(value, &(_, _, num_y))| (value * i_pow(num_y)).re)
1256 .collect();
1257 }
1258 let mut acc = vec![Complex64::new(0.0, 0.0); masks.len()];
1259 for j in 0..d {
1260 let row = &self.sv.state[j * d..(j + 1) * d];
1261 for (slot, &(xmask, zmask, _)) in acc.iter_mut().zip(masks) {
1262 let sign = if (j & zmask).count_ones() & 1 == 1 {
1263 -1.0
1264 } else {
1265 1.0
1266 };
1267 *slot += row[j ^ xmask] * sign;
1268 }
1269 }
1270 acc.iter()
1271 .zip(masks)
1272 .map(|(value, &(_, _, num_y))| (value * i_pow(num_y)).re)
1273 .collect()
1274 }
1275}
1276
1277impl Backend for DensityMatrixBackend {
1278 fn name(&self) -> &'static str {
1279 "density_matrix"
1280 }
1281
1282 fn as_any(&self) -> Option<&dyn std::any::Any> {
1283 Some(self)
1284 }
1285
1286 fn resolved(&self) -> crate::sim::ResolvedBackend {
1287 crate::sim::ResolvedBackend::DensityMatrix
1288 }
1289
1290 fn schmidt_values(&mut self, _subsystem: &[usize]) -> Result<Vec<f64>> {
1291 Err(crate::error::PrismError::BackendUnsupported {
1292 backend: self.name().to_string(),
1293 operation: "Schmidt values of a mixed state".to_string(),
1294 })
1295 }
1296
1297 /// Declined: the fidelity of two mixtures is not an inner product, and a
1298 /// mixture holds no statevector for the dense route to export.
1299 fn overlap_sq(&self, _other: &dyn Backend) -> Result<f64> {
1300 Err(crate::error::PrismError::BackendUnsupported {
1301 backend: self.name().to_string(),
1302 operation: "state overlap".to_string(),
1303 })
1304 }
1305
1306 /// Partial trace of the `4^n` buffer, `2^(n + k)` reads. At `k = n` the
1307 /// buffer itself comes back, its rows and columns in `subsystem` order.
1308 fn reduced_density_matrix(&mut self, subsystem: &[usize]) -> Result<Vec<Complex64>> {
1309 schmidt::validate_qubit_set(subsystem, self.num_qubits)?;
1310 let dim = reduced_density::reduced_density_side(self.name(), subsystem.len())?;
1311 #[cfg(feature = "gpu")]
1312 let readback = self
1313 .sv
1314 .gpu_state()
1315 .map(|_| self.sv.export_statevector())
1316 .transpose()?;
1317 #[cfg(feature = "gpu")]
1318 let buffer = readback.as_deref().unwrap_or(self.sv.state.as_slice());
1319 #[cfg(not(feature = "gpu"))]
1320 let buffer = self.sv.state.as_slice();
1321 let mut rho = self.partial_trace(buffer, subsystem);
1322 reduced_density::normalize_trace(&mut rho, dim);
1323 Ok(rho)
1324 }
1325
1326 fn placement(&self) -> crate::sim::Placement {
1327 if self.sv.is_gpu_resident() {
1328 crate::sim::Placement::Device
1329 } else {
1330 crate::sim::Placement::Host
1331 }
1332 }
1333
1334 /// With a device attached the host cap does not apply: the mixture is
1335 /// budgeted against free VRAM instead, before anything is allocated.
1336 fn init(&mut self, num_qubits: usize, num_classical_bits: usize) -> Result<()> {
1337 #[cfg(feature = "gpu")]
1338 if let Some(ctx) = &self.gpu_context {
1339 check_device_budget(ctx, num_qubits)?;
1340 self.num_qubits = num_qubits;
1341 self.classical_bits = vec![false; num_classical_bits];
1342 return self.sv.init(2 * num_qubits, 0);
1343 }
1344
1345 crate::backend::check_state_allocation(
1346 "density_matrix",
1347 num_qubits,
1348 crate::backend::max_density_matrix_qubits(),
1349 crate::backend::DM_QUBIT_CAP_ENV,
1350 )?;
1351
1352 self.num_qubits = num_qubits;
1353 self.classical_bits = vec![false; num_classical_bits];
1354 self.sv.init(2 * num_qubits, 0)
1355 }
1356
1357 fn supports_initial_state(&self) -> bool {
1358 true
1359 }
1360
1361 /// Starts from the pure mixture `|psi><psi|`, so the buffer is the `4^n`
1362 /// outer product of `amplitudes` with its own conjugate. The cap check in
1363 /// [`init`](Self::init) runs first, before that buffer is sized.
1364 fn init_from_amplitudes(
1365 &mut self,
1366 amplitudes: Vec<Complex64>,
1367 num_classical_bits: usize,
1368 ) -> Result<()> {
1369 crate::backend::validate_initial_amplitudes(&litudes)?;
1370 let num_qubits = amplitudes.len().trailing_zeros() as usize;
1371 self.init(num_qubits, num_classical_bits)?;
1372
1373 #[cfg(feature = "gpu")]
1374 if let Some((ctx, gpu)) = self.device() {
1375 return dk::outer_product(&ctx, gpu, num_qubits, &litudes);
1376 }
1377
1378 let d = amplitudes.len();
1379 for (row, &) in amplitudes.iter().enumerate() {
1380 let dst = &mut self.sv.state[row * d..(row + 1) * d];
1381 for (entry, &col) in dst.iter_mut().zip(amplitudes.iter()) {
1382 *entry = amp * col.conj();
1383 }
1384 }
1385 Ok(())
1386 }
1387
1388 fn apply(&mut self, instruction: &Instruction) -> Result<()> {
1389 match instruction {
1390 Instruction::Gate { gate, targets } => self.apply_unitary(gate, targets),
1391 Instruction::Barrier { .. } => Ok(()),
1392 Instruction::Measure {
1393 qubit,
1394 classical_bit,
1395 } => self.apply_measure(*qubit, *classical_bit),
1396 Instruction::Reset { qubit } => self.apply_reset(*qubit),
1397 Instruction::Conditional {
1398 condition,
1399 gate,
1400 targets,
1401 } => self.apply_conditional(condition, gate, targets),
1402 Instruction::Region(region) => self.apply_region(region),
1403 }
1404 }
1405
1406 fn classical_results(&self) -> &[bool] {
1407 &self.classical_bits
1408 }
1409
1410 fn probabilities(&self) -> Result<Vec<f64>> {
1411 let d = self.dim();
1412 #[cfg(feature = "gpu")]
1413 if let Some(gpu) = self.sv.gpu_state() {
1414 let mut diag = dk::diagonal(gpu.context(), gpu, self.num_qubits)?;
1415 for p in &mut diag {
1416 *p = p.max(0.0);
1417 }
1418 return Ok(diag);
1419 }
1420 let mut probs = vec![0.0f64; d];
1421 for (k, p) in probs.iter_mut().enumerate() {
1422 *p = self.sv.state[k * d + k].re.max(0.0);
1423 }
1424 Ok(probs)
1425 }
1426
1427 fn num_qubits(&self) -> usize {
1428 self.num_qubits
1429 }
1430
1431 /// `MultiFused` and `Multi2q` apply their constituents one at a time here
1432 /// rather than through the tiled kernels, which the ket register's shifted
1433 /// indices would reorder.
1434 fn supports_fused_gates(&self) -> bool {
1435 true
1436 }
1437
1438 /// The mixture is a `2n`-qubit statevector, so every fusion floor is
1439 /// reached at half the circuit width the statevector needs.
1440 fn fusion_state_qubits(&self, num_qubits: usize) -> usize {
1441 2 * num_qubits
1442 }
1443
1444 fn qubit_probability(&self, qubit: usize) -> Result<f64> {
1445 self.prob_one(qubit)
1446 }
1447
1448 fn reset(&mut self, qubit: usize) -> Result<()> {
1449 self.apply_reset(qubit)
1450 }
1451
1452 fn supports_pauli_expectation(&self) -> bool {
1453 true
1454 }
1455
1456 /// `Tr(rho P_k)` per observable, the mixed-state reading of the trait's
1457 /// `<psi|P_k|psi>`. `rho` is trace-one by construction, so no
1458 /// normalization divide is needed.
1459 fn pauli_expectations(&self, observables: &[Vec<PauliTerm>]) -> Result<Vec<f64>> {
1460 let masks = observables
1461 .iter()
1462 .map(|observable| crate::sim::pauli_masks(observable, self.num_qubits))
1463 .collect::<Result<Vec<_>>>()?;
1464 Ok(self.expectations_pauli(&masks))
1465 }
1466
1467 /// On the device the four entries come from the Pauli sums `T`, `Z`, `X`,
1468 /// and `Y` on `qubit`: the diagonal is `(T +- Z) / 2` and the off-diagonal
1469 /// pair is `(X +- Y) / 2`, where `Y` carries the row sign and no `i`.
1470 fn reduced_density_matrix_1q(&self, qubit: usize) -> Result<[[Complex64; 2]; 2]> {
1471 let n = self.num_qubits;
1472 let d = self.dim();
1473 let bit = 1usize << qubit;
1474 #[cfg(feature = "gpu")]
1475 if let Some(gpu) = self.sv.gpu_state() {
1476 let b = bit as u64;
1477 let sums = dk::pauli_sums(gpu.context(), gpu, n, &[(0, 0), (0, b), (b, 0), (b, b)])?;
1478 let (t, z, x, y) = (sums[0], sums[1], sums[2], sums[3]);
1479 return Ok([
1480 [(t + z) * 0.5, (x + y) * 0.5],
1481 [(x - y) * 0.5, (t - z) * 0.5],
1482 ]);
1483 }
1484 let others = 1usize << (n - 1);
1485 let mut r00 = Complex64::new(0.0, 0.0);
1486 let mut r01 = Complex64::new(0.0, 0.0);
1487 let mut r10 = Complex64::new(0.0, 0.0);
1488 let mut r11 = Complex64::new(0.0, 0.0);
1489 for m in 0..others {
1490 let base = (m & (bit - 1)) | ((m >> qubit) << (qubit + 1));
1491 let i1 = base | bit;
1492 r00 += self.sv.state[base * d + base];
1493 r01 += self.sv.state[base * d + i1];
1494 r10 += self.sv.state[i1 * d + base];
1495 r11 += self.sv.state[i1 * d + i1];
1496 }
1497 Ok([[r00, r01], [r10, r11]])
1498 }
1499
1500 /// Evolve `rho -> K rho K^dagger` for an arbitrary `K`, on the same kernel
1501 /// selection as the one-qubit branch of `apply_unitary`. Trajectories never
1502 /// route here, since `supports_noisy_per_shot` excludes the density matrix in
1503 /// favour of `apply_1q_kraus` on the mixture; this keeps the trait method
1504 /// allocation-free on every backend that holds a state.
1505 fn apply_1q_matrix(&mut self, qubit: usize, matrix: &[[Complex64; 2]; 2]) -> Result<()> {
1506 self.apply_1q_sandwich(qubit, matrix)
1507 }
1508}