paulistrings/truncation/
builtin.rs

1//! The built-in truncation policies and combinators, and the helpers their partitioned and device forms share.
2
3use super::TruncationPolicy;
4use crate::collectives::Collectives;
5use crate::engine::LOG_TARGET;
6use crate::pauli_sum::PauliSum;
7use crate::rng::Rng;
8use num_complex::Complex64;
9use rayon::prelude::*;
10use std::cell::RefCell;
11use std::sync::atomic::{AtomicU64, Ordering};
12
13thread_local! {
14    /// [`TopN`]'s per-thread squared-magnitude buffer.
15    // Taken out and put back, never borrowed across a parallel section: rayon can steal a nested `propagate` onto this thread and re-enter `finalize_layer`.
16    static MAGNITUDES: RefCell<Vec<f64>> = const { RefCell::new(Vec::new()) };
17}
18
19/// Drop terms whose coefficient magnitude is at most `epsilon`.
20///
21/// The test is `|c|² > ε²`, so magnitudes below about `1.6e-162` count as zero.
22#[derive(Clone, Copy, Debug, PartialEq)]
23pub struct CoefficientThreshold(
24    /// Magnitude threshold. Terms with `|coeff| <= epsilon` are dropped.
25    pub f64,
26);
27
28impl<const W: usize> TruncationPolicy<W> for CoefficientThreshold {
29    fn keep_term(&self, _x: &[u64; W], _z: &[u64; W], c: Complex64) -> bool {
30        let eps = self.0;
31        eps < 0.0 || c.norm_sqr() > eps * eps
32    }
33
34    fn finalizes_layer(&self) -> bool {
35        false
36    }
37}
38
39/// Drop terms whose Pauli weight (number of non-identity qubits) exceeds `k`.
40#[derive(Clone, Copy, Debug, PartialEq)]
41pub struct WeightCutoff(
42    /// Maximum allowed Pauli weight. Terms with weight `> k` are dropped.
43    pub u32,
44);
45
46impl<const W: usize> TruncationPolicy<W> for WeightCutoff {
47    fn keep_term(&self, x: &[u64; W], z: &[u64; W], _c: Complex64) -> bool {
48        let weight: u32 = (0..W).map(|i| (x[i] | z[i]).count_ones()).sum();
49        weight <= self.0
50    }
51
52    fn finalizes_layer(&self) -> bool {
53        false
54    }
55}
56
57/// Retain the `n` terms of largest coefficient magnitude.
58///
59/// With `t` the `n`-th largest magnitude, every term above `t` is kept, plus as many terms at exactly `t` as fit in `n`, the first in bucket order; which tied terms survive is not a semantic promise.
60/// A sum of more than `n` terms keeps exactly `n`, and `n == 0` clears; a NaN coefficient is never kept, so a sum with fewer than `n` other terms keeps those.
61/// Magnitudes are compared squared, so those below about `1.6e-162` tie at zero.
62#[derive(Clone, Copy, Debug, PartialEq)]
63pub struct TopN(
64    /// The retained term count.
65    pub usize,
66);
67
68impl<const W: usize> TruncationPolicy<W> for TopN {
69    fn finalize_layer(&self, sum: &mut PauliSum<W>) {
70        let n = self.0;
71        if sum.len() <= n {
72            return;
73        }
74        if n == 0 {
75            sum.clear();
76            return;
77        }
78
79        let total = sum.len();
80        let mut buffer = MAGNITUDES.take();
81        if buffer.len() < total {
82            buffer.resize(total, 0.0);
83        }
84        let magnitudes = &mut buffer[..total];
85        {
86            let view = &*sum;
87            let num_buckets = view.num_buckets();
88            let mut handles: Vec<&mut [f64]> = Vec::with_capacity(num_buckets);
89            let mut rest: &mut [f64] = magnitudes;
90            for b in 0..num_buckets {
91                let (head, tail) = rest.split_at_mut(view.bucket_len(b));
92                handles.push(head);
93                rest = tail;
94            }
95            debug_assert!(rest.is_empty(), "bucket lengths must sum to len()");
96            handles.into_par_iter().enumerate().for_each(|(b, dst)| {
97                for (d, c) in dst.iter_mut().zip(view.bucket(b).2.iter()) {
98                    *d = c.norm_sqr();
99                }
100            });
101        }
102
103        // A NaN sorts below every number, so it becomes the boundary only when fewer than `n` terms are finite.
104        magnitudes.select_nth_unstable_by(n - 1, |a, b| {
105            b.partial_cmp(a).unwrap_or_else(|| a.is_nan().cmp(&b.is_nan()))
106        });
107        let boundary = magnitudes[n - 1];
108        // Everything above the pivot sits before it after the selection.
109        let above = magnitudes[..n - 1].par_iter().filter(|&&m| m > boundary).count();
110
111        MAGNITUDES.set(buffer);
112
113        retain_with_boundary_quota(
114            sum,
115            |m| {
116                if m.is_nan() {
117                    core::cmp::Ordering::Less
118                } else {
119                    // Unordered only against a NaN boundary, which every finite term is above.
120                    m.partial_cmp(&boundary).unwrap_or(core::cmp::Ordering::Greater)
121                }
122            },
123            n - above,
124        );
125    }
126
127    /// Panics above one partition, where the `n`-th largest of a split sum has no collective form yet.
128    fn finalize_layer_partitioned(&self, local: &mut PauliSum<W>, collectives: &dyn Collectives) {
129        assert!(
130            collectives.size() == 1,
131            "TopN({}) on {} partitions: partitioned exact TopN is not yet supported; ApproxTopN \
132             is the partitioned alternative.",
133            self.0,
134            collectives.size(),
135        );
136        self.finalize_layer(local);
137    }
138}
139
140/// How many boundary terms each bucket keeps: `quota` handed out in bucket order, each bucket taking at most its own count.
141fn bucket_quotas(boundary_counts: &[usize], quota: usize) -> Vec<usize> {
142    let mut remaining = quota;
143    boundary_counts
144        .iter()
145        .map(|&count| {
146            let taken = count.min(remaining);
147            remaining -= taken;
148            taken
149        })
150        .collect()
151}
152
153/// Keep every term `classify` puts above the boundary (`Greater`) and the first `quota` it puts at it (`Equal`), in bucket order.
154pub(crate) fn retain_with_boundary_quota<const W: usize>(
155    sum: &mut PauliSum<W>,
156    classify: impl Fn(f64) -> core::cmp::Ordering + Sync,
157    quota: usize,
158) {
159    use core::cmp::Ordering;
160    let boundary_counts: Vec<usize> = (0..sum.num_buckets())
161        .into_par_iter()
162        .map(|b| {
163            sum.bucket(b).2.iter().filter(|c| classify(c.norm_sqr()) == Ordering::Equal).count()
164        })
165        .collect();
166    let quotas = bucket_quotas(&boundary_counts, quota);
167    sum.retain_per_bucket(|b| {
168        let mut remaining = quotas[b];
169        let classify = &classify;
170        move |_: &[u64; W], _: &[u64; W], c: Complex64| match classify(c.norm_sqr()) {
171            Ordering::Greater => true,
172            Ordering::Equal if remaining > 0 => {
173                remaining -= 1;
174                true
175            }
176            Ordering::Equal | Ordering::Less => false,
177        }
178    });
179}
180
181/// Bins of [`ApproxTopN`]'s histogram, one per `f64` exponent.
182pub(crate) const APPROX_BINS: usize = 2048;
183
184/// Retain `n` terms, cutting at an octave of `|c|²` instead of selecting exactly.
185///
186/// Every octave of `|c|²` that fits whole is kept from the top down; the first that does not fit, the boundary octave, contributes as many terms as fit in `n`, the first in bucket order (rank order across partitions).
187/// A sum of more than `n` terms keeps exactly `n` (fewer only if coefficients are NaN), and `n == 0` clears.
188/// Which boundary-octave terms survive is not a semantic promise.
189/// Partition-exact: a split sum's octave histogram is the sum of its partitions', so every partition cuts at the boundary one partition would.
190#[derive(Clone, Copy, Debug, PartialEq)]
191pub struct ApproxTopN(
192    /// The retained term count.
193    pub usize,
194);
195
196/// [`octave_edge`]'s decision, shared by the host, partitioned and device paths.
197#[derive(Clone, Copy, Debug, PartialEq)]
198pub(crate) enum EdgeDecision {
199    /// The whole sum fits within `n`: nothing is dropped.
200    KeepAll,
201    /// `n == 0`.
202    Clear,
203    /// Keep every term above octave `bin` and `quota` terms of octave `bin`, `kept_above + quota == n` in all.
204    Boundary {
205        /// The boundary octave, `norm_sqr().to_bits() >> 52`.
206        bin: usize,
207        /// Terms above the boundary octave, across the whole sum.
208        kept_above: usize,
209        /// Boundary-octave terms kept, across the whole sum.
210        quota: usize,
211    },
212}
213
214/// The octave of `|c|²`, [`APPROX_BINS`] wide.
215fn octave(norm_sqr: f64) -> usize {
216    (norm_sqr.to_bits() >> 52) as usize
217}
218
219/// `|c|²` against the boundary octave `bin`; a NaN is below every octave.
220pub(crate) fn classify_octave(norm_sqr: f64, bin: usize) -> core::cmp::Ordering {
221    if norm_sqr.is_nan() {
222        core::cmp::Ordering::Less
223    } else {
224        octave(norm_sqr).cmp(&bin)
225    }
226}
227
228/// Population of each octave of `|c|²`, binned by the exponent bits `norm_sqr().to_bits() >> 52`.
229pub(crate) fn octave_histogram<const W: usize>(sum: &PauliSum<W>) -> [u32; APPROX_BINS] {
230    debug_assert!(sum.len() <= u32::MAX as usize, "len exceeds bin counters");
231    let num_buckets = sum.num_buckets();
232    let tasks = (rayon::current_num_threads() * 4).clamp(1, num_buckets);
233    (0..tasks)
234        .into_par_iter()
235        .map(|t| {
236            let mut h = [0u32; APPROX_BINS];
237            for b in (num_buckets * t / tasks)..(num_buckets * (t + 1) / tasks) {
238                for c in sum.bucket(b).2 {
239                    h[octave(c.norm_sqr())] += 1;
240                }
241            }
242            h
243        })
244        .reduce(
245            || [0u32; APPROX_BINS],
246            |mut a, b| {
247                for (x, y) in a.iter_mut().zip(b.iter()) {
248                    *x += y;
249                }
250                a
251            },
252        )
253}
254
255/// The boundary octave [`ApproxTopN`] cuts at, from a histogram of the whole sum, local or all-reduced.
256pub(crate) fn octave_edge<C>(hist: &[C], total_len: usize, n: usize) -> EdgeDecision
257where
258    C: Copy + Into<u64>,
259{
260    debug_assert_eq!(hist.len(), APPROX_BINS, "octave_edge: wrong histogram size");
261    if total_len <= n {
262        return EdgeDecision::KeepAll;
263    }
264    if n == 0 {
265        return EdgeDecision::Clear;
266    }
267
268    let mut kept_above = 0usize;
269    for bin in (0..APPROX_BINS).rev() {
270        let population = hist[bin].into() as usize;
271        if kept_above + population > n {
272            return EdgeDecision::Boundary { bin, kept_above, quota: n - kept_above };
273        }
274        kept_above += population;
275    }
276    unreachable!("a histogram of more than n terms overflows n at some octave")
277}
278
279/// Apply an [`EdgeDecision`] to one sum or partition, keeping `local_quota` boundary-octave terms.
280pub(crate) fn retain_at_edge<const W: usize>(
281    sum: &mut PauliSum<W>,
282    edge: EdgeDecision,
283    local_quota: usize,
284) {
285    match edge {
286        EdgeDecision::KeepAll => {}
287        EdgeDecision::Clear => sum.clear(),
288        EdgeDecision::Boundary { bin, .. } => {
289            retain_with_boundary_quota(sum, |m| classify_octave(m, bin), local_quota);
290        }
291    }
292}
293
294/// This partition's share of a boundary quota: what is left of `quota` after the lower ranks' boundary terms, at most `local_count`.
295///
296/// One `allreduce_sum_u64` of a length-`P` vector holding each rank's `local_count` in its slot; every rank calls it with the same `quota`.
297pub(crate) fn local_boundary_quota(
298    collectives: &dyn Collectives,
299    local_count: usize,
300    quota: usize,
301) -> usize {
302    let rank = collectives.rank() as usize;
303    let mut counts = vec![0u64; collectives.size() as usize];
304    counts[rank] = local_count as u64;
305    collectives.allreduce_sum_u64(&mut counts);
306    let below: u64 = counts[..rank].iter().sum();
307    (quota as u64).saturating_sub(below).min(local_count as u64) as usize
308}
309
310impl<const W: usize> TruncationPolicy<W> for ApproxTopN {
311    fn finalize_layer(&self, sum: &mut PauliSum<W>) {
312        let n = self.0;
313        let total = sum.len();
314        if total <= n {
315            return;
316        }
317        if n == 0 {
318            sum.clear();
319            return;
320        }
321
322        let edge = octave_edge(&octave_histogram(sum), total, n);
323        let quota = match edge {
324            EdgeDecision::Boundary { quota, .. } => quota,
325            EdgeDecision::KeepAll | EdgeDecision::Clear => 0,
326        };
327        retain_at_edge(sum, edge, quota);
328    }
329
330    /// One `allreduce_sum_u64` of `[len, histogram…]`, the single-partition edge walk, and at a boundary one more of the per-rank boundary counts for this rank's quota; no early exit, since that would desynchronize the group.
331    fn finalize_layer_partitioned(&self, local: &mut PauliSum<W>, collectives: &dyn Collectives) {
332        if collectives.size() == 1 {
333            self.finalize_layer(local);
334            return;
335        }
336        let histogram = octave_histogram(local);
337
338        // `u64`, since `P` partitions' `u32` bins can overflow a `u32`.
339        let mut packed = [0u64; 1 + APPROX_BINS];
340        packed[0] = local.len() as u64;
341        for (slot, &count) in packed[1..].iter_mut().zip(histogram.iter()) {
342            *slot = u64::from(count);
343        }
344        collectives.allreduce_sum_u64(&mut packed);
345
346        let total = packed[0] as usize;
347        let edge = octave_edge(&packed[1..], total, self.0);
348        let local_quota = match edge {
349            EdgeDecision::Boundary { bin, quota, .. } => {
350                local_boundary_quota(collectives, histogram[bin] as usize, quota)
351            }
352            EdgeDecision::KeepAll | EdgeDecision::Clear => 0,
353        };
354        retain_at_edge(local, edge, local_quota);
355    }
356
357    fn supports_partitioned(&self) -> bool {
358        true
359    }
360}
361
362/// Replace the sum by one Pauli string, drawn with probability `|c|² / Σ|c|²` and given coefficient `1`, whenever it holds more than `cache` terms.
363///
364/// One seed is one Monte Carlo trajectory: the draw at the `k`-th layer pass is a function of `(seed, k)` only, and the pass counter lives in the policy, so reusing a policy continues its sequence.
365/// Partitioned runs agree with unpartitioned ones in distribution, not string for string, and count passes and collapses on rank 0's policy only.
366/// A collapse panics if the sum's total weight is zero or not finite.
367#[derive(Debug)]
368pub struct CollapseSample {
369    /// Largest sum left untouched.
370    pub cache: usize,
371    /// Trajectory seed.
372    pub seed: u64,
373    /// Layer passes seen.
374    calls: AtomicU64,
375    /// Collapses performed.
376    collapses: AtomicU64,
377}
378
379impl CollapseSample {
380    /// A fresh trajectory: collapse above `cache` terms, draws keyed by `seed`.
381    pub fn new(cache: usize, seed: u64) -> Self {
382        Self { cache, seed, calls: AtomicU64::new(0), collapses: AtomicU64::new(0) }
383    }
384
385    /// Collapses performed so far (rank 0's count in partitioned mode).
386    pub fn collapses(&self) -> u64 {
387        self.collapses.load(Ordering::Relaxed)
388    }
389
390    /// Claim the next pass index.
391    pub(crate) fn next_call(&self) -> u64 {
392        self.calls.fetch_add(1, Ordering::Relaxed)
393    }
394
395    pub(crate) fn count_collapse(&self) -> u64 {
396        self.collapses.fetch_add(1, Ordering::Relaxed) + 1
397    }
398
399    /// The point of the cumulative weight pass `call` draws; panics unless `total` is positive and finite.
400    pub(crate) fn target(&self, call: u64, total: f64, len: usize) -> f64 {
401        assert!(
402            total.is_finite() && total > 0.0,
403            "CollapseSample: cannot draw by weight from {len} terms with Σ|c|² = {total}",
404        );
405        Rng::from_key(&[self.seed, call]).uniform() * total
406    }
407}
408
409/// `Σ|c|²` of each bucket, in bucket order.
410pub(crate) fn bucket_norms<const W: usize>(sum: &PauliSum<W>) -> Vec<f64> {
411    (0..sum.num_buckets())
412        .into_par_iter()
413        .map(|b| sum.bucket(b).2.iter().map(|c| c.norm_sqr()).sum())
414        .collect()
415}
416
417/// The slot whose stretch of the running total contains `target`, and the offset into it; past the end, the last positive slot.
418pub(crate) fn pick_slot(
419    weights: impl IntoIterator<Item = f64>,
420    target: f64,
421) -> Option<(usize, f64)> {
422    let mut cumulative = 0.0f64;
423    let mut last = None;
424    for (i, w) in weights.into_iter().enumerate() {
425        if w > 0.0 {
426            if target < cumulative + w {
427                return Some((i, target - cumulative));
428            }
429            last = Some((i, target - cumulative));
430        }
431        cumulative += w;
432    }
433    last
434}
435
436/// Reduce `sum` to the single string at `target` of its cumulative weight, with coefficient `1`; `norms` is [`bucket_norms`] of `sum`.
437pub(crate) fn collapse_to_target<const W: usize>(
438    sum: &mut PauliSum<W>,
439    norms: &[f64],
440    target: f64,
441) {
442    let (b, rest) = pick_slot(norms.iter().copied(), target)
443        .expect("collapse_to_target: no bucket has positive weight");
444    let (pos, _) = pick_slot(sum.bucket(b).2.iter().map(|c| c.norm_sqr()), rest)
445        .expect("collapse_to_target: a positive bucket has a positive term");
446    let (x, z) = (sum.bucket(b).0[pos], sum.bucket(b).1[pos]);
447    sum.clear();
448    sum.buckets_mut()[b].push(x, z, Complex64::new(1.0, 0.0));
449    sum.recount();
450}
451
452/// Equal when `cache`, `seed` and both counters are.
453impl PartialEq for CollapseSample {
454    fn eq(&self, other: &Self) -> bool {
455        self.cache == other.cache
456            && self.seed == other.seed
457            && self.calls.load(Ordering::Relaxed) == other.calls.load(Ordering::Relaxed)
458            && self.collapses() == other.collapses()
459    }
460}
461
462impl<const W: usize> TruncationPolicy<W> for CollapseSample {
463    fn finalize_layer(&self, sum: &mut PauliSum<W>) {
464        let call = self.next_call();
465        let len = sum.len();
466        if len <= self.cache {
467            return;
468        }
469        let norms = bucket_norms(sum);
470        let total: f64 = norms.iter().sum();
471        collapse_to_target(sum, &norms, self.target(call, total, len));
472        let n = self.count_collapse();
473        log::debug!(
474            target: LOG_TARGET,
475            "collapse_sample: {len} terms, sum |c|^2 = {total:.6e}, collapsed to one (collapse {n})",
476        );
477    }
478
479    /// Reduces `[len, pass]` (the pass from rank 0) and the per-partition `Σ|c|²`, then picks a partition by weight and a term within it, as [`finalize_layer`](TruncationPolicy::finalize_layer) picks a bucket and then a term.
480    fn finalize_layer_partitioned(&self, local: &mut PauliSum<W>, collectives: &dyn Collectives) {
481        if collectives.size() == 1 {
482            self.finalize_layer(local);
483            return;
484        }
485        let rank = collectives.rank() as usize;
486        let call = if rank == 0 { self.next_call() } else { 0 };
487        let mut head = [local.len() as u64, call];
488        collectives.allreduce_sum_u64(&mut head);
489        let [len, call] = head;
490        if len as usize <= self.cache {
491            return;
492        }
493
494        let norms = bucket_norms(local);
495        let mut weights = vec![0.0f64; collectives.size() as usize];
496        weights[rank] = norms.iter().sum();
497        collectives.allreduce_sum_f64(&mut weights);
498        let total: f64 = weights.iter().sum();
499        let target = self.target(call, total, len as usize);
500        let (chosen, rest) = pick_slot(weights.iter().copied(), target)
501            .expect("CollapseSample: a positive total has a positive partition");
502        if rank == chosen {
503            collapse_to_target(local, &norms, rest);
504        } else {
505            local.clear();
506        }
507        if rank == 0 {
508            let collapses = self.count_collapse();
509            log::debug!(
510                target: LOG_TARGET,
511                "collapse_sample: {len} terms over {} partitions, sum |c|^2 = {total:.6e}, \
512                 collapsed to one on partition {chosen} (collapse {collapses})",
513                collectives.size(),
514            );
515        }
516    }
517
518    fn supports_partitioned(&self) -> bool {
519        true
520    }
521}
522
523/// Both policies: a term is kept if both keep it, and both layer passes run, first then second.
524#[derive(Clone, Copy, Debug, PartialEq)]
525pub struct And<A, B>(
526    /// First policy.
527    pub A,
528    /// Second policy.
529    pub B,
530);
531
532impl<const W: usize, A, B> TruncationPolicy<W> for And<A, B>
533where
534    A: TruncationPolicy<W>,
535    B: TruncationPolicy<W>,
536{
537    fn keep_term(&self, x: &[u64; W], z: &[u64; W], c: Complex64) -> bool {
538        self.0.keep_term(x, z, c) && self.1.keep_term(x, z, c)
539    }
540
541    fn finalize_layer(&self, sum: &mut PauliSum<W>) {
542        self.0.finalize_layer(sum);
543        self.1.finalize_layer(sum);
544    }
545
546    fn finalizes_layer(&self) -> bool {
547        self.0.finalizes_layer() || self.1.finalizes_layer()
548    }
549
550    fn finalize_layer_partitioned(&self, local: &mut PauliSum<W>, collectives: &dyn Collectives) {
551        self.0.finalize_layer_partitioned(local, collectives);
552        self.1.finalize_layer_partitioned(local, collectives);
553    }
554
555    fn supports_partitioned(&self) -> bool {
556        self.0.supports_partitioned() && self.1.supports_partitioned()
557    }
558}
559
560/// Either of two per-term filters: a term is kept if either keeps it.
561///
562/// `Or` combines per-term filters only: an operand with a layer pass ([`TopN`], [`ApproxTopN`], [`CollapseSample`], or an [`And`] containing one) is rejected, since "either keeps" has no meaning for a pass over the whole sum.
563/// Use [`And`] to add a filter to a layer pass.
564#[derive(Clone, Copy, Debug, PartialEq)]
565pub struct Or<A, B>(pub(crate) A, pub(crate) B);
566
567impl<A, B> Or<A, B> {
568    /// Either of `first` and `second`.
569    ///
570    /// The operands are checked in [`finalizes_layer`](TruncationPolicy::finalizes_layer), which every driver calls before the first layer, since a layer pass is a property of the policy at a width `W` and `new` has no `W`.
571    pub fn new(first: A, second: B) -> Self {
572        Self(first, second)
573    }
574}
575
576/// Why an `Or` of these operands is rejected, or `None` if both are per-term filters.
577pub(crate) fn or_operand_error(first_has_pass: bool, second_has_pass: bool) -> Option<String> {
578    let which = match (first_has_pass, second_has_pass) {
579        (false, false) => return None,
580        (true, false) => "its first operand has",
581        (false, true) => "its second operand has",
582        (true, true) => "both operands have",
583    };
584    Some(format!(
585        "Or combines per-term filters only, but {which} a layer pass (TopN, ApproxTopN or \
586         CollapseSample, alone or inside an And); use And to add a filter to a layer pass"
587    ))
588}
589
590impl<const W: usize, A, B> TruncationPolicy<W> for Or<A, B>
591where
592    A: TruncationPolicy<W>,
593    B: TruncationPolicy<W>,
594{
595    fn keep_term(&self, x: &[u64; W], z: &[u64; W], c: Complex64) -> bool {
596        self.0.keep_term(x, z, c) || self.1.keep_term(x, z, c)
597    }
598
599    /// `false`; panics if either operand has a layer pass.
600    fn finalizes_layer(&self) -> bool {
601        if let Some(message) = or_operand_error(self.0.finalizes_layer(), self.1.finalizes_layer())
602        {
603            panic!("{message}");
604        }
605        false
606    }
607}
608
609#[cfg(test)]
610mod tests;