Skip to main content

p3_binary_dft/
lch.rs

1//! The Lin–Chung–Han additive NTT.
2
3use core::marker::PhantomData;
4
5use p3_binary_field::TowerLevel;
6use p3_matrix::Matrix;
7use p3_matrix::dense::RowMajorMatrix;
8use p3_maybe_rayon::prelude::*;
9use p3_util::log2_strict_usize;
10
11use crate::domain::{domain_point, subspace_polynomial};
12use crate::traits::AdditiveNtt;
13
14/// The Lin–Chung–Han additive NTT over the Cantor-basis domain.
15///
16/// Twiddles are index shifts (D8): at stage `j` and butterfly block `blk` the twiddle is
17/// `W_j(shift) + domain_point(blk << 1)`, so there is no twiddle table and no per-size
18/// precomputation.
19///
20/// `W_j` is `F_2`-linear and `domain_point(0)` is zero, so over the subspace itself — the
21/// coset with `shift = 0` — the first block of every stage has a zero twiddle and its
22/// butterfly collapses to a single addition. The inner loop takes that as a separate case,
23/// which removes one multiply in `2/ℓ` of them.
24#[derive(Clone, Debug, Default)]
25pub struct LchNtt<F> {
26    _marker: PhantomData<F>,
27}
28
29/// The number of field elements one butterfly task covers on each side of a block.
30///
31/// Stage `j` has `2^(ℓ − 1 − j)` blocks of `half = 2^j · width` elements per side, so cutting
32/// each side into pieces of this size leaves `n · width / (2 · BUTTERFLY_GRAIN)` pieces at
33/// every stage, independent of `j`: the wide stages, which have too few blocks to fill a
34/// machine, are split from within instead. Stages with `half ≤ BUTTERFLY_GRAIN` keep a single
35/// piece per side and pay nothing for the extra level.
36///
37/// At a few nanoseconds per butterfly a piece of this size is microseconds of work, well above
38/// the cost of handing a task to another thread, while still leaving hundreds of pieces per
39/// stage at the smallest useful heights.
40pub(crate) const BUTTERFLY_GRAIN: usize = 1 << 10;
41
42impl<F: TowerLevel> AdditiveNtt<F> for LchNtt<F> {
43    fn shifted_ntt_batch(&self, mut mat: RowMajorMatrix<F>, shift: F) -> RowMajorMatrix<F> {
44        let width = mat.width();
45        let log_n = log2_strict_usize(mat.height());
46
47        for j in (0..log_n).rev() {
48            let half = (1 << j) * width;
49            // D8: the block starting at row `b` of the coset `shift + S_ℓ` has twiddle
50            // `W_j(shift) + point(b >> j)`, and block `blk` starts at row `blk << (j + 1)`.
51            let base = subspace_polynomial::<F>(j, shift);
52            mat.values
53                .par_chunks_mut(half << 1)
54                .enumerate()
55                .for_each(|(blk, block)| {
56                    let t = base + domain_point::<F>(blk << 1);
57                    let zero = t.is_zero();
58                    let (lo, hi) = block.split_at_mut(half);
59                    let butterfly = |lo: &mut [F], hi: &mut [F]| {
60                        if zero {
61                            // (u, v) ↦ (u, u + v)
62                            for (u, v) in lo.iter_mut().zip(hi) {
63                                *v += *u;
64                            }
65                        } else {
66                            for (u, v) in lo.iter_mut().zip(hi) {
67                                // (u, v) ↦ (u + t·v, u + t·v + v)
68                                *u += t * *v;
69                                *v += *u;
70                            }
71                        }
72                    };
73                    // Pairs are independent across the block, so a block wider than the grain
74                    // is split further rather than run on a single thread.
75                    if half <= BUTTERFLY_GRAIN {
76                        butterfly(lo, hi);
77                    } else {
78                        lo.par_chunks_mut(BUTTERFLY_GRAIN)
79                            .zip(hi.par_chunks_mut(BUTTERFLY_GRAIN))
80                            .for_each(|(lo, hi)| butterfly(lo, hi));
81                    }
82                });
83        }
84        mat
85    }
86
87    fn shifted_intt_batch(&self, mut mat: RowMajorMatrix<F>, shift: F) -> RowMajorMatrix<F> {
88        let width = mat.width();
89        let log_n = log2_strict_usize(mat.height());
90
91        for j in 0..log_n {
92            let half = (1 << j) * width;
93            // Twiddles as derived in `shifted_ntt_batch`, with the stages run in reverse.
94            let base = subspace_polynomial::<F>(j, shift);
95            mat.values
96                .par_chunks_mut(half << 1)
97                .enumerate()
98                .for_each(|(blk, block)| {
99                    let t = base + domain_point::<F>(blk << 1);
100                    let zero = t.is_zero();
101                    let (lo, hi) = block.split_at_mut(half);
102                    let butterfly = |lo: &mut [F], hi: &mut [F]| {
103                        if zero {
104                            // (u', v') ↦ (u = u', v = u' + v')
105                            for (u, v) in lo.iter_mut().zip(hi) {
106                                *v += *u;
107                            }
108                        } else {
109                            for (u, v) in lo.iter_mut().zip(hi) {
110                                // (u', v') ↦ (u = u' + t·v, v = u' + v')
111                                *v += *u;
112                                *u += t * *v;
113                            }
114                        }
115                    };
116                    // Pairs are independent across the block, so a block wider than the grain
117                    // is split further rather than run on a single thread.
118                    if half <= BUTTERFLY_GRAIN {
119                        butterfly(lo, hi);
120                    } else {
121                        lo.par_chunks_mut(BUTTERFLY_GRAIN)
122                            .zip(hi.par_chunks_mut(BUTTERFLY_GRAIN))
123                            .for_each(|(lo, hi)| butterfly(lo, hi));
124                    }
125                });
126        }
127        mat
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use alloc::vec;
134
135    use p3_binary_field::{
136        BinaryField8, BinaryField16, BinaryField32, BinaryField64, BinaryField128, TowerLevel,
137    };
138    use p3_matrix::dense::RowMajorMatrix;
139    use proptest::prelude::*;
140
141    use super::LchNtt;
142    use crate::naive::NaiveAdditiveNtt;
143    use crate::traits::AdditiveNtt;
144
145    /// Builds an element of any level from a 64-bit pattern, repeating it for wider levels.
146    fn sample<F: TowerLevel>(bits: u64) -> F {
147        F::from_le_byte_iter(bits.to_le_bytes().into_iter().cycle())
148    }
149
150    /// Builds a matrix whose entries are distinct functions of the seed and the position.
151    fn matrix<F: TowerLevel>(log_n: usize, width: usize, seed: u64) -> RowMajorMatrix<F> {
152        RowMajorMatrix::new(
153            (0..(width << log_n))
154                .map(|i| {
155                    sample::<F>(
156                        seed.wrapping_mul(0x9e37_79b9_7f4a_7c15)
157                            .wrapping_add(i as u64),
158                    )
159                })
160                .collect(),
161            width,
162        )
163    }
164
165    /// `LchNtt` agrees with the oracle on a random matrix and a random coset.
166    fn check_matches_naive<F: TowerLevel>(log_n: usize, width: usize, seed: u64, shift: u64) {
167        let coeffs = matrix::<F>(log_n, width, seed);
168        let shift = sample::<F>(shift);
169
170        let fast = LchNtt::<F>::default().shifted_ntt_batch(coeffs.clone(), shift);
171        let slow = NaiveAdditiveNtt::<F>::default().shifted_ntt_batch(coeffs, shift);
172        assert_eq!(fast, slow);
173    }
174
175    proptest! {
176        #![proptest_config(ProptestConfig::with_cases(32))]
177
178        #[test]
179        fn lch_matches_naive_at_8_bits(
180            log_n in 0usize..=8,
181            width in 1usize..=5,
182            seed in any::<u64>(),
183            shift in any::<u64>(),
184        ) {
185            check_matches_naive::<BinaryField8>(log_n, width, seed, shift);
186        }
187
188        #[test]
189        fn lch_matches_naive_at_16_bits(
190            log_n in 0usize..=8,
191            width in 1usize..=5,
192            seed in any::<u64>(),
193            shift in any::<u64>(),
194        ) {
195            check_matches_naive::<BinaryField16>(log_n, width, seed, shift);
196        }
197
198        #[test]
199        fn lch_matches_naive_at_32_bits(
200            log_n in 0usize..=8,
201            width in 1usize..=5,
202            seed in any::<u64>(),
203            shift in any::<u64>(),
204        ) {
205            check_matches_naive::<BinaryField32>(log_n, width, seed, shift);
206        }
207
208        #[test]
209        fn lch_matches_naive_at_64_bits(
210            log_n in 0usize..=8,
211            width in 1usize..=5,
212            seed in any::<u64>(),
213            shift in any::<u64>(),
214        ) {
215            check_matches_naive::<BinaryField64>(log_n, width, seed, shift);
216        }
217
218        #[test]
219        fn lch_matches_naive_at_128_bits(
220            log_n in 0usize..=8,
221            width in 1usize..=5,
222            seed in any::<u64>(),
223            shift in any::<u64>(),
224        ) {
225            check_matches_naive::<BinaryField128>(log_n, width, seed, shift);
226        }
227
228        #[test]
229        fn lch_round_trips(
230            log_n in 0usize..=10,
231            width in 1usize..=3,
232            seed in any::<u64>(),
233            shift in any::<u64>(),
234        ) {
235            let coeffs = matrix::<BinaryField16>(log_n, width, seed);
236            let shift = sample::<BinaryField16>(shift);
237            let ntt = LchNtt::<BinaryField16>::default();
238            let evals = ntt.shifted_ntt_batch(coeffs.clone(), shift);
239            prop_assert_eq!(ntt.shifted_intt_batch(evals, shift), coeffs);
240        }
241
242        /// `LchNtt`'s low-degree extension agrees with the oracle's, on a coset too, and the
243        /// input rows reappear as the prefix: the correspondence Phase 3 folds along.
244        #[test]
245        fn lch_lde_matches_naive(
246            log_n in 0usize..=6,
247            added in 0usize..=3,
248            width in 1usize..=3,
249            seed in any::<u64>(),
250            shift in any::<u64>(),
251        ) {
252            let coeffs = matrix::<BinaryField16>(log_n, width, seed);
253            let shift = sample::<BinaryField16>(shift);
254
255            let lde = LchNtt::<BinaryField16>::default()
256                .shifted_lde_batch(coeffs.clone(), added, shift);
257            let naive = NaiveAdditiveNtt::<BinaryField16>::default()
258                .shifted_lde_batch(coeffs.clone(), added, shift);
259            prop_assert_eq!(&lde, &naive);
260            prop_assert_eq!(&lde.values[..coeffs.values.len()], &coeffs.values[..]);
261        }
262    }
263
264    /// An index of `S_ℓ` past the bit width of `F` calls for a Cantor basis vector this level
265    /// does not have.
266    #[test]
267    #[should_panic]
268    fn shifted_ntt_batch_rejects_l_past_the_bit_width() {
269        // `BinaryField8` has `2^LOG_BITS = 8` Cantor basis vectors, indices `0..8`.
270        let coeffs = matrix::<BinaryField8>((1 << BinaryField8::LOG_BITS) + 1, 1, 0);
271        let _ = LchNtt::<BinaryField8>::default().ntt_batch(coeffs);
272    }
273
274    /// A height that is not a power of two has no well-defined `ℓ`.
275    #[test]
276    #[should_panic]
277    fn shifted_ntt_batch_rejects_a_non_power_of_two_height() {
278        let coeffs = RowMajorMatrix::new(vec![sample::<BinaryField8>(0); 3], 1);
279        let _ = LchNtt::<BinaryField8>::default().ntt_batch(coeffs);
280    }
281
282    /// The transform is `F_2`-linear in the coefficient vector.
283    #[test]
284    fn lch_is_linear() {
285        let ntt = LchNtt::<BinaryField16>::default();
286        let a = matrix::<BinaryField16>(5, 2, 1);
287        let b = matrix::<BinaryField16>(5, 2, 2);
288        let sum = RowMajorMatrix::new(
289            a.values
290                .iter()
291                .zip(&b.values)
292                .map(|(x, y)| *x + *y)
293                .collect(),
294            2,
295        );
296
297        let lhs = ntt.ntt_batch(sum);
298        let rhs_a = ntt.ntt_batch(a);
299        let rhs_b = ntt.ntt_batch(b);
300        for (i, v) in lhs.values.iter().enumerate() {
301            assert_eq!(*v, rhs_a.values[i] + rhs_b.values[i]);
302        }
303    }
304
305    /// The same data transformed at two levels agrees after embedding: the domain does not
306    /// depend on the level, so neither does the transform.
307    #[test]
308    fn levels_agree_after_embedding() {
309        const LOG_N: usize = 6;
310        let coeffs32 = matrix::<BinaryField32>(LOG_N, 1, 7);
311        let coeffs128 = RowMajorMatrix::new(
312            coeffs32
313                .values
314                .iter()
315                .map(|v| BinaryField128::from_repr(u128::from(v.to_repr())))
316                .collect(),
317            1,
318        );
319
320        let small = LchNtt::<BinaryField32>::default().ntt_batch(coeffs32);
321        let large = LchNtt::<BinaryField128>::default().ntt_batch(coeffs128);
322        for (s, l) in small.values.iter().zip(&large.values) {
323            assert_eq!(u128::from(s.to_repr()), l.to_repr());
324        }
325    }
326}