Skip to main content

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(&amplitudes)?;
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, &amplitudes);
1376        }
1377
1378        let d = amplitudes.len();
1379        for (row, &amp) 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}