Skip to main content

p3_binary_dft/
lch.rs

1//! The Lin–Chung–Han additive NTT.
2
3use alloc::vec::Vec;
4use core::marker::PhantomData;
5
6use p3_binary_field::TowerLevel;
7use p3_matrix::Matrix;
8use p3_matrix::dense::RowMajorMatrix;
9use p3_maybe_rayon::prelude::*;
10use p3_util::{log2_ceil_usize, log2_floor_usize, log2_strict_usize};
11
12use crate::butterfly::ButterflyField;
13use crate::domain::{domain_point, domain_point_steps};
14use crate::staging::{Dispatch, StagedRuns, for_each_staged_tile};
15use crate::traits::AdditiveNtt;
16
17/// The Lin–Chung–Han additive NTT over the Cantor-basis domain.
18///
19/// Twiddles are index shifts (D8): at stage `j` and butterfly block `blk` the twiddle is
20/// `W_j(shift) + domain_point(blk << 1)`, so there is no twiddle table.
21///
22/// Consecutive blocks differ by a fixed increment, so a block takes its twiddle from the one
23/// before it rather than walking its own index.
24/// The `ℓ - 1` increments are shared by every stage and are the only per-size precomputation.
25///
26/// `W_j` is `F_2`-linear and `domain_point(0)` is zero, so over the subspace itself — the
27/// coset with `shift = 0` — the first block of every stage has a zero twiddle and its
28/// butterfly collapses to a single addition. The inner loop takes that as a separate case,
29/// which removes one multiply in `2/ℓ` of them.
30///
31/// At the wide stages a pair of rows is megabytes apart, so a stage alone costs a pass over
32/// memory. The schedule groups adjacent stages into row sets closed under all of them.
33#[derive(Clone, Debug, Default)]
34pub struct LchNtt<F> {
35    _marker: PhantomData<F>,
36}
37
38/// The number of field elements one butterfly task covers on each side of a block.
39///
40/// Stage `j` has `2^(ℓ − 1 − j)` blocks of `half = 2^j · width` elements per side, so cutting
41/// each side into pieces of this size leaves `n · width / (2 · BUTTERFLY_GRAIN)` pieces at
42/// every stage, independent of `j`: the wide stages, which have too few blocks to fill a
43/// machine, are split from within instead. Stages with `half ≤ BUTTERFLY_GRAIN` keep a single
44/// piece per side and instead gather `BUTTERFLY_GRAIN / half` whole blocks into one task, so a
45/// task is a piece of this size on either side of the crossover.
46///
47/// At a few nanoseconds per butterfly a piece of this size is microseconds of work, well above
48/// the cost of handing a task to another thread, while still leaving hundreds of pieces per
49/// stage at the smallest useful heights.
50pub(crate) const BUTTERFLY_GRAIN: usize = 1 << 10;
51
52/// The byte budget of one contiguous row tile.
53///
54/// A worker holds one tile for every stage its rows are closed under, so a tile per worker has
55/// to fit a core's private second-level cache.
56///
57/// Doubling buys one more stage inside the tile. A sweep of 32 KiB to 256 KiB flattens here.
58const DEEP_TILE_BYTES: usize = 128 * 1024;
59
60/// The byte budget of one staging tile.
61///
62/// Every worker holds one for the whole group, so the budget is paid per worker.
63///
64/// Halving it fuses one stage fewer, which is one more traversal. A sweep of 32 KiB to 1 MiB
65/// is flat from here up, so this is the smallest budget at the floor.
66const STAGING_TILE_BYTES: usize = 64 * 1024;
67
68/// The smallest number of stages a staging tile has to cover to be worth gathering for.
69///
70/// One stage gathered and scattered moves the memory traffic one plain pass moves.
71/// It then adds a copy in each direction, so grouping pays only from two stages up.
72const MIN_FUSED_STAGES: usize = 2;
73
74/// Workers a transform needs before the stages above the tile are worth staging.
75///
76/// Staging trades one pass per stage for a strided gather and scatter. Few workers issue those
77/// passes far below what memory will serve, so the copies have nothing to pay for them.
78///
79/// The turn is fixed by the last-level cache, which a `no_std` crate cannot read, so the worker
80/// count is the signal left. Measured, staging loses up to a third below this count and wins
81/// from it up. Fewer workers give up a win rather than take a loss.
82const STAGED_WORKERS: usize = 16;
83
84/// The smallest cache line among the supported targets, in bytes.
85///
86/// A gather that picks up less than a line from each row it visits pays for bytes it cannot
87/// use. Apple Silicon reports 128, so this is the smallest line rather than the transfer
88/// granularity: past it no target wastes more than half of a line.
89///
90/// A staging tile therefore holds whole runs of rows wherever a row is shorter than this.
91const CACHE_LINE_BYTES: usize = 64;
92
93impl<F: ButterflyField> AdditiveNtt<F> for LchNtt<F> {
94    fn shifted_ntt_batch(&self, mat: RowMajorMatrix<F>, shift: F) -> RowMajorMatrix<F> {
95        transform::<F, false>(mat, shift)
96    }
97
98    fn shifted_intt_batch(&self, mat: RowMajorMatrix<F>, shift: F) -> RowMajorMatrix<F> {
99        transform::<F, true>(mat, shift)
100    }
101
102    fn ntt_batch_padded(
103        &self,
104        mut mat: RowMajorMatrix<F>,
105        log_inv_rate: usize,
106    ) -> RowMajorMatrix<F> {
107        let width = mat.width();
108        let log_n = log2_strict_usize(mat.height());
109        assert!(log_inv_rate <= log_n, "padding exceeds matrix height");
110        assert!(log_n <= 1 << F::LOG_BITS, "domain exceeds field dimension");
111
112        // The zero tail starts where the message ends, which is what fixes the coset size.
113        transform_cosets::<F>(&mut mat.values, width, log_n - log_inv_rate);
114        mat
115    }
116}
117
118/// Stage twiddle bases and the increments between consecutive block twiddles.
119struct Twiddles<F> {
120    /// `W_j(shift)`, the twiddle of the first block of stage `j`, for every stage.
121    bases: Vec<F>,
122    /// The difference between consecutive block twiddles, indexed by trailing-zero count.
123    steps: Vec<F>,
124}
125
126impl<F: TowerLevel> Twiddles<F> {
127    /// Precompute the twiddle state a transform of `2^log_n` rows over `shift + S_ℓ` needs.
128    fn new(log_n: usize, shift: F) -> Self {
129        // `W_0` is the identity, and `W_{j+1}(x) = W_j(x)² + W_j(x)`.
130        //
131        // So the bases form a chain, one squaring each rather than `j` squarings apiece.
132        let mut base = shift;
133        let bases = (0..log_n)
134            .map(|_| {
135                let current = base;
136                base = base.square() + base;
137                current
138            })
139            .collect();
140
141        // An increment depends only on a block index's trailing-zero count, never on the stage,
142        // so one table serves every stage and each stage uses the prefix it reaches.
143        // A height of one runs no stage and needs no table.
144        let steps = domain_point_steps::<F>(log_n.saturating_sub(1));
145
146        Self { bases, steps }
147    }
148
149    /// The twiddle of butterfly block `block` at stage `stage`.
150    #[inline]
151    fn at(&self, stage: usize, block: usize) -> F {
152        self.bases[stage] + domain_point::<F>(block << 1)
153    }
154
155    /// What to add to block `index - 1`'s twiddle to reach block `index`'s.
156    #[inline]
157    fn step(&self, index: usize) -> F {
158        self.steps[index.trailing_zeros() as usize]
159    }
160}
161
162/// One stage, as a single pass over every row.
163fn stage_pass<F: ButterflyField, const INVERSE: bool>(
164    values: &mut [F],
165    width: usize,
166    j: usize,
167    twiddles: &Twiddles<F>,
168) {
169    let half = (1 << j) * width;
170    let per_task = (BUTTERFLY_GRAIN / half).max(1);
171    let task_len = per_task * (half << 1);
172    let task = |(task, group): (usize, &mut [F])| {
173        let first = task * per_task;
174        let mut t = twiddles.at(j, first);
175        // Invariant: blocks are visited in ascending index order.
176        // Carrying the twiddle from one block to the next relies on it.
177        for (i, block) in group.chunks_mut(half << 1).enumerate() {
178            if i != 0 {
179                t += twiddles.step(first + i);
180            }
181            let (lo, hi) = block.split_at_mut(half);
182            let butterfly = |lo: &mut [F], hi: &mut [F]| {
183                F::butterfly::<INVERSE>(lo, hi, t);
184            };
185            // Pairs are independent across the block.
186            //
187            // So a block wider than the grain is split further, not run on one thread.
188            if half <= BUTTERFLY_GRAIN {
189                butterfly(lo, hi);
190            } else {
191                lo.par_chunks_mut(BUTTERFLY_GRAIN)
192                    .zip(hi.par_chunks_mut(BUTTERFLY_GRAIN))
193                    .for_each(|(lo, hi)| butterfly(lo, hi));
194            }
195        }
196    };
197
198    // A pass covering one task has nothing to spread over the machine.
199    //
200    // Dispatching it anyway costs a thread handoff per stage.
201    // The narrow stages of a short transform pay that once each and get nothing back.
202    if values.len() > task_len {
203        values.par_chunks_mut(task_len).enumerate().for_each(task);
204    } else {
205        values.chunks_mut(task_len).enumerate().for_each(task);
206    }
207}
208
209/// Run the `log_rows` adjacent stages a tile of `2^log_rows` rows is closed under.
210///
211/// Tile row `q` sits in global block `(index << (log_rows - 1 - jj)) + (q >> (jj + 1))` at the
212/// stage pairing rows `2^jj` apart. The second term is what the tile-local pass already walks,
213/// so only the first has to be passed in.
214///
215/// Forward runs its widest stage first, inverse the narrowest.
216fn tile_stages<F: ButterflyField, const INVERSE: bool>(
217    tile: &mut [F],
218    row_len: usize,
219    log_rows: usize,
220    stage_base: usize,
221    index: usize,
222    twiddles: &Twiddles<F>,
223) {
224    for step in 0..log_rows {
225        let jj = if INVERSE { step } else { log_rows - 1 - step };
226        let half = (1 << jj) * row_len;
227
228        // The tile's own blocks continue the global numbering from here.
229        let first = index << (log_rows - 1 - jj);
230        let mut t = twiddles.at(stage_base + jj, first);
231
232        // Invariant: blocks are visited in ascending index order.
233        for (i, block) in tile.chunks_mut(half << 1).enumerate() {
234            if i != 0 {
235                t += twiddles.step(first + i);
236            }
237            let (lo, hi) = block.split_at_mut(half);
238            F::butterfly::<INVERSE>(lo, hi, t);
239        }
240    }
241}
242
243/// Finish the bottom stages inside each contiguous tile of `2^log_rows` rows.
244///
245/// Those stages pair rows less than `2^log_rows` apart, so a tile is closed under all of them
246/// and is read once and written once instead of once per stage.
247fn deep_tiles<F: ButterflyField, const INVERSE: bool>(
248    values: &mut [F],
249    width: usize,
250    log_rows: usize,
251    twiddles: &Twiddles<F>,
252) {
253    values
254        .par_chunks_mut((1 << log_rows) * width)
255        .enumerate()
256        .for_each(|(index, tile)| {
257            tile_stages::<F, INVERSE>(tile, width, log_rows, 0, index, twiddles);
258        });
259}
260
261/// The runs that the `depth` stages ending at stage `top` gather, `2^log_slab` matrix rows to a
262/// run.
263///
264/// A task owns one butterfly block of the group's widest stage and one run of rows inside it,
265/// so with `S = 2^(top + 1 - depth)` the narrowest stage's row distance, its staged run `k`
266/// begins at matrix row
267///
268/// ```text
269///     R(t, k) = S · ((t / runs) · 2^depth + k) + (t % runs) · 2^log_slab ,   runs = S / 2^log_slab
270/// ```
271const fn staged_runs(width: usize, top: usize, depth: usize, log_slab: usize) -> StagedRuns {
272    StagedRuns::new((1 << log_slab) * width, top + 1 - depth - log_slab, depth)
273}
274
275/// Run the `depth` stages ending at stage `top` through a staging tile, `2^log_slab` matrix
276/// rows to a staged row.
277///
278/// # Algorithm
279///
280/// Write `S = 2^(top + 1 - depth)`, the row distance of the group's narrowest stage. For a
281/// block `blk` of stage `top` and an offset `r < S`, stage the rows
282///
283/// ```text
284///     R(k) = S · (blk · 2^depth + k) + r ,     k = 0 .. 2^depth
285/// ```
286///
287/// Stage `top - s` moves `2^(depth - 1 - s)` in `k`, so the set is closed under the group, and
288/// since `r < S` the global block of `R(k)` is `(blk << s) + (k >> (depth - s))` — what a
289/// contiguous tile of index `blk` sees. The pairs `(blk, r)` partition the matrix.
290///
291/// # Panics
292/// Panics if a task's row walk reaches past the end of `values`.
293fn fused_group<F: ButterflyField, const INVERSE: bool>(
294    values: &mut [F],
295    width: usize,
296    log_n: usize,
297    top: usize,
298    depth: usize,
299    log_slab: usize,
300    twiddles: &Twiddles<F>,
301) {
302    // A staged row is a run of `2^log_slab` matrix rows.
303    //
304    // So a run has to fit inside the distance the group's narrowest stage pairs across.
305    debug_assert!(depth >= 1 && log_slab + depth <= top + 1);
306    debug_assert!(top < log_n);
307
308    let row_len = (1 << log_slab) * width;
309    // The tiles the staged pass lays out, each `2^depth` staged rows.
310    let tasks = values.len() / (row_len << depth);
311
312    // Rayon splits a range as far as it likes, and a split is what a staging tile belongs to.
313    //
314    // So bound the splits to a few per thread, rather than let every task allocate one.
315    let min_len = (tasks / (4 * current_num_threads())).max(1);
316    for_each_staged_tile(
317        values,
318        staged_runs(width, top, depth, log_slab),
319        Dispatch::Parallel { min_len },
320        |tile, block| {
321            tile_stages::<F, INVERSE>(tile, row_len, depth, top + 1 - depth, block, twiddles);
322        },
323    );
324}
325
326/// How wide the two kinds of row tile are for a given element size and matrix width.
327struct Schedule {
328    /// Base-two log of the rows in one contiguous tile.
329    log_tile_rows: usize,
330    /// Base-two log of the staged rows one group of stages covers.
331    log_staged_rows: usize,
332    /// Base-two log of the matrix rows one staged row holds.
333    log_slab_rows: usize,
334}
335
336impl Schedule {
337    /// The tile shapes a matrix gets from the worker count the transform will run on.
338    fn new<F: TowerLevel>(width: usize, log_n: usize) -> Self {
339        Self::for_workers::<F>(width, log_n, current_num_threads())
340    }
341
342    /// Divide each byte budget by the bytes one row occupies, then cut the contiguous tile
343    /// back until there is one per worker.
344    ///
345    /// A tile is also a task, so growing it past one per worker would buy depth no thread is
346    /// left to run. Below [`STAGED_WORKERS`] workers nothing is staged at all.
347    fn for_workers<F: TowerLevel>(width: usize, log_n: usize, workers: usize) -> Self {
348        let row_bytes = core::mem::size_of::<F>() * width;
349
350        // A tile of at least one row, even where a row alone overruns the budget.
351        let capacity = log2_floor_usize((DEEP_TILE_BYTES / row_bytes).max(1));
352        let log_tile_rows = capacity.min(log_n.saturating_sub(log2_ceil_usize(workers)));
353
354        // Rows shorter than a cache line are staged in runs, so every byte a gather fetches is
355        // a byte the tile uses.
356        let log_slab_rows =
357            log2_ceil_usize(CACHE_LINE_BYTES.div_ceil(row_bytes)).min(log_tile_rows);
358
359        // A staged row is a whole run, so it is the run that the staging budget divides.
360        let slab_bytes = row_bytes << log_slab_rows;
361        let log_staged_rows = if workers >= STAGED_WORKERS {
362            log2_floor_usize((STAGING_TILE_BYTES / slab_bytes).max(1))
363        } else {
364            0
365        };
366
367        Self {
368            log_tile_rows,
369            log_staged_rows,
370            log_slab_rows,
371        }
372    }
373}
374
375/// Run the stages `low` up to, but not including, `high` as one group.
376///
377/// A group of a single stage is a plain pass: gathering two rows half the matrix apart moves
378/// the bytes the pass moves, and copies them twice on top.
379fn group_pass<F: ButterflyField, const INVERSE: bool>(
380    values: &mut [F],
381    width: usize,
382    log_n: usize,
383    low: usize,
384    high: usize,
385    log_slab: usize,
386    twiddles: &Twiddles<F>,
387) {
388    if high - low == 1 {
389        stage_pass::<F, INVERSE>(values, width, low, twiddles);
390    } else {
391        fused_group::<F, INVERSE>(
392            values,
393            width,
394            log_n,
395            high - 1,
396            high - low,
397            log_slab,
398            twiddles,
399        );
400    }
401}
402
403/// Run the `top` narrowest stages of the transform under one tile shape.
404///
405/// # Algorithm
406///
407/// Stages below the contiguous tile run inside it. The stages above it are cut into groups of
408/// adjacent stages, each short enough that the rows it is closed under fit one staging tile:
409///
410/// ```text
411///     stage   top-1 .............. t+2f  t+2f-1 ..... t+f  t+f-1 ..... t  t-1 ... 0
412///             \____ staged group ____/  \_ staged group _/  \_ group _/  \_ tile _/
413/// ```
414///
415/// The boundaries are counted up from the tile, so a short remainder falls to the top group.
416/// The forward direction runs the groups from the top and the tile last, the inverse the other
417/// way round.
418fn run<F: ButterflyField, const INVERSE: bool>(
419    values: &mut [F],
420    width: usize,
421    log_n: usize,
422    top: usize,
423    twiddles: &Twiddles<F>,
424    schedule: &Schedule,
425) {
426    // The schedule is the caller's, so bring it inside what this call can use.
427    //
428    // - A tile is at most the range of stages asked for.
429    // - A staged row is a run of matrix rows.
430    // - That run must not straddle a butterfly of the narrowest stage above the tile.
431    let tile = schedule.log_tile_rows.min(top);
432    let slab = schedule.log_slab_rows.min(tile);
433    let group = if schedule.log_staged_rows >= MIN_FUSED_STAGES {
434        schedule.log_staged_rows
435    } else {
436        1
437    };
438    let groups = (top - tile).div_ceil(group);
439    let bounds = |g: usize| {
440        let low = tile + g * group;
441        (low, (low + group).min(top))
442    };
443
444    if INVERSE {
445        if tile > 0 {
446            deep_tiles::<F, INVERSE>(values, width, tile, twiddles);
447        }
448        for g in 0..groups {
449            let (low, high) = bounds(g);
450            group_pass::<F, INVERSE>(values, width, log_n, low, high, slab, twiddles);
451        }
452    } else {
453        for g in (0..groups).rev() {
454            let (low, high) = bounds(g);
455            group_pass::<F, INVERSE>(values, width, log_n, low, high, slab, twiddles);
456        }
457        if tile > 0 {
458            deep_tiles::<F, INVERSE>(values, width, tile, twiddles);
459        }
460    }
461}
462
463/// Run the `top` narrowest stages over a row-major buffer of `width` columns, in place.
464///
465/// Stages are numbered by the distance they pair rows across, so stage zero is the narrowest.
466/// The whole transform is every stage the row count allows.
467///
468/// Asking for fewer than all of them leaves the matrix part-way through the network.
469/// The two directions traverse that network in opposite orders, so they stop at opposite ends:
470///
471/// ```text
472///     forward   stage l-1 ... stage top   then   stage top-1 ... stage 0
473///               \___ the caller's own ___/        \___ this call _____/
474///
475///     inverse   stage 0 ... stage top-1   then   stage top ... stage l-1
476///               \___ this call _______/          \___ the caller's own ___/
477/// ```
478///
479/// So a forward caller has to have run the wider stages already.
480/// An inverse caller still has them ahead of it.
481///
482/// # Panics
483/// Panics if the row count is not a power of two.
484/// Panics if the subspace dimension it calls for exceeds the bit width of the level.
485/// Panics if the stage count exceeds that dimension.
486pub(crate) fn transform_stages<F: ButterflyField, const INVERSE: bool>(
487    values: &mut [F],
488    width: usize,
489    top: usize,
490    shift: F,
491) {
492    let log_n = log2_strict_usize(values.len() / width);
493    assert!(log_n <= 1 << F::LOG_BITS, "domain exceeds field dimension");
494    assert!(top <= log_n, "stage count exceeds the domain dimension");
495    let twiddles = Twiddles::new(log_n, shift);
496
497    // A matrix no larger than a tile is cache-resident for the whole transform, so blocking it
498    // would only add bookkeeping to stages already free of memory traffic.
499    if core::mem::size_of_val(values) <= DEEP_TILE_BYTES {
500        for step in 0..top {
501            let j = if INVERSE { step } else { top - 1 - step };
502            stage_pass::<F, INVERSE>(values, width, j, &twiddles);
503        }
504        return;
505    }
506
507    let schedule = Schedule::new::<F>(width, log_n);
508    run::<F, INVERSE>(values, width, log_n, top, &twiddles, &schedule);
509}
510
511/// Transform a buffer whose leading `2^log_message` rows hold the message and the rest zeros.
512///
513/// # Algorithm
514///
515/// The stages that cross the padding read a zero on the high side of every butterfly:
516///
517/// ```text
518///     (u, 0)  ->  (u + t*0, u + t*0 + 0)  =  (u, u)
519/// ```
520///
521/// They therefore copy the message into each coset of its own subspace and compute nothing.
522/// What is left is one shifted transform per coset, over that coset's own rows:
523///
524/// ```text
525///     coset 0    the message itself, transformed over the subspace
526///     coset c    a contiguous copy of coset 0, then its own shifted transform
527/// ```
528///
529/// A coset's shift is the domain point its first row sits at.
530/// The subspace polynomials are `F_2`-linear, so its blocks read the network's own twiddles.
531///
532/// The copy is what a coset costs instead of one butterfly pass per skipped stage.
533pub(crate) fn transform_cosets<F: ButterflyField>(
534    values: &mut [F],
535    width: usize,
536    log_message: usize,
537) {
538    // One coset is the message's own footprint, so the buffer divides into whole cosets.
539    let len = width << log_message;
540    let (first, rest) = values.split_at_mut(len);
541
542    // Coset `c + 1` starts at domain point `(c + 1) * 2^log_message`, which is its shift.
543    // Transforming it right after the copy finds its rows still in cache.
544    rest.par_chunks_mut(len).enumerate().for_each(|(c, coset)| {
545        coset.copy_from_slice(first);
546        let shift = domain_point::<F>((c + 1) << log_message);
547        transform_stages::<F, false>(coset, width, log_message, shift);
548    });
549
550    // The subspace itself is the coset with no shift, and it is the source every copy read.
551    transform_stages::<F, false>(first, width, log_message, F::ZERO);
552}
553
554/// Transform a matrix in place, in whichever direction the flag selects.
555fn transform<F: ButterflyField, const INVERSE: bool>(
556    mut mat: RowMajorMatrix<F>,
557    shift: F,
558) -> RowMajorMatrix<F> {
559    let width = mat.width();
560    let log_n = log2_strict_usize(mat.height());
561    transform_stages::<F, INVERSE>(&mut mat.values, width, log_n, shift);
562    mat
563}
564
565#[cfg(test)]
566mod tests {
567    use alloc::{format, vec};
568
569    use p3_binary_field::{
570        BinaryField8, BinaryField16, BinaryField32, BinaryField64, BinaryField128, Ghash128,
571        TowerLevel,
572    };
573    use p3_field::PrimeCharacteristicRing;
574    use p3_matrix::Matrix;
575    use p3_matrix::dense::RowMajorMatrix;
576    use p3_util::log2_strict_usize;
577    use proptest::prelude::*;
578
579    use super::{
580        ButterflyField, DEEP_TILE_BYTES, LchNtt, MIN_FUSED_STAGES, STAGED_WORKERS, Schedule,
581        Twiddles, run, stage_pass, staged_runs,
582    };
583    use crate::domain::{domain_point, subspace_polynomial};
584    use crate::naive::NaiveAdditiveNtt;
585    use crate::traits::AdditiveNtt;
586
587    /// The widths a sweep covers.
588    ///
589    /// `1 ..= 8` walks a row across the SIMD lane count of every level.
590    /// So a stage meets a packed prefix with a scalar tail, a packed prefix, and a tail alone.
591    ///
592    /// 16 is the folding-block width the encoder commits at, and 64 is the width whose row
593    /// passes a cache line at every element size, so a staged row of it is a single row.
594    const WIDTHS: [usize; 10] = [1, 2, 3, 4, 5, 6, 7, 8, 16, 64];
595
596    /// The widths a tile-boundary sweep covers.
597    ///
598    /// A shape's boundary height is `2^t`, where `2^t · row_bytes` is one tile budget.
599    /// So the matrix a boundary sweep builds is four budgets whatever the width, and the cost
600    /// is one budget per width rather than one per element.
601    ///
602    /// This subset keeps the two rows [`HEAVY_LOG_HEIGHTS`] leaves below its boundary — one
603    /// element and two of them — plus an odd row, a SIMD-sized row and a wide one.
604    const BOUNDARY_WIDTHS: [usize; 5] = [1, 2, 3, 16, 64];
605
606    /// The heights every sweep covers.
607    ///
608    /// At production budgets only the wide end reaches the blocked schedule, and no staged run
609    /// executes. The staged path is pinned by the boundary and production sweeps instead, at
610    /// named worker counts.
611    const LOG_HEIGHTS: core::ops::RangeInclusive<usize> = 0..=10;
612
613    /// The heights the heavy sweep covers on top of [`LOG_HEIGHTS`].
614    ///
615    /// By `2^14` every row wider than 8 bytes has passed one tile budget; the three narrower
616    /// shapes block only in the boundary sweep. At width 64 this is minutes of oracle in a
617    /// debug build, so the sweep is ignored.
618    const HEAVY_LOG_HEIGHTS: core::ops::RangeInclusive<usize> = 0..=14;
619
620    /// The shifts a sweep covers.
621    ///
622    /// Zero is the subspace itself, where the first block of every stage has a zero twiddle and
623    /// takes the butterfly's separate case. The other shift has bits throughout, so no stage's
624    /// twiddle is accidentally zero.
625    const SHIFTS: [u64; 2] = [0, 0x5555_1234_9abc_def0];
626
627    /// Builds an element of any level from a 64-bit pattern, repeating it for wider levels.
628    fn sample<F: ButterflyField>(bits: u64) -> F {
629        F::from_le_byte_iter(bits.to_le_bytes().into_iter().cycle())
630    }
631
632    /// Builds a matrix whose entries are distinct functions of the seed and the position.
633    fn matrix<F: ButterflyField>(log_n: usize, width: usize, seed: u64) -> RowMajorMatrix<F> {
634        RowMajorMatrix::new(
635            (0..(width << log_n))
636                .map(|i| {
637                    sample::<F>(
638                        seed.wrapping_mul(0x9e37_79b9_7f4a_7c15)
639                            .wrapping_add(i as u64),
640                    )
641                })
642                .collect(),
643            width,
644        )
645    }
646
647    /// The forward transform with every twiddle walked out from its own block index, in one
648    /// serial pass and with no zero shortcut.
649    fn twiddle_walk_ntt<F: ButterflyField>(
650        mut mat: RowMajorMatrix<F>,
651        shift: F,
652    ) -> RowMajorMatrix<F> {
653        let width = mat.width();
654        let log_n = log2_strict_usize(mat.height());
655        for j in (0..log_n).rev() {
656            let half = (1 << j) * width;
657            let base = subspace_polynomial::<F>(j, shift);
658            for (blk, block) in mat.values.chunks_mut(half << 1).enumerate() {
659                let t = base + domain_point::<F>(blk << 1);
660                let (lo, hi) = block.split_at_mut(half);
661                for (u, v) in lo.iter_mut().zip(hi) {
662                    *u += t * *v;
663                    *v += *u;
664                }
665            }
666        }
667        mat
668    }
669
670    /// The inverse transform walked the same way, every twiddle from its own block index.
671    /// One serial pass, with the stage order reversed and the butterfly undone.
672    fn twiddle_walk_intt<F: ButterflyField>(
673        mut mat: RowMajorMatrix<F>,
674        shift: F,
675    ) -> RowMajorMatrix<F> {
676        let width = mat.width();
677        let log_n = log2_strict_usize(mat.height());
678        for j in 0..log_n {
679            let half = (1 << j) * width;
680            let base = subspace_polynomial::<F>(j, shift);
681            for (blk, block) in mat.values.chunks_mut(half << 1).enumerate() {
682                let t = base + domain_point::<F>(blk << 1);
683                let (lo, hi) = block.split_at_mut(half);
684                for (u, v) in lo.iter_mut().zip(hi) {
685                    *v += *u;
686                    *u += t * *v;
687                }
688            }
689        }
690        mat
691    }
692
693    /// One stage at a time over the whole matrix, which is the schedule blocking has to match.
694    fn stage_by_stage<F: ButterflyField, const INVERSE: bool>(
695        mat: &mut RowMajorMatrix<F>,
696        twiddles: &Twiddles<F>,
697    ) {
698        let width = mat.width();
699        let log_n = log2_strict_usize(mat.height());
700        stages_one_at_a_time::<F, INVERSE>(&mut mat.values, width, log_n, twiddles);
701    }
702
703    /// The narrowest `top` stages as plain passes, in the order the direction traverses them.
704    fn stages_one_at_a_time<F: ButterflyField, const INVERSE: bool>(
705        values: &mut [F],
706        width: usize,
707        top: usize,
708        twiddles: &Twiddles<F>,
709    ) {
710        for step in 0..top {
711            let j = if INVERSE { step } else { top - 1 - step };
712            stage_pass::<F, INVERSE>(values, width, j, twiddles);
713        }
714    }
715
716    /// Every stage range of one shape, blocked against the same stages run one pass at a time.
717    fn check_every_stage_range_agrees<F: ButterflyField>(
718        log_n: usize,
719        width: usize,
720        schedule: &Schedule,
721    ) {
722        let coeffs = matrix::<F>(log_n, width, 3);
723        for shift_bits in SHIFTS {
724            let shift = sample::<F>(shift_bits);
725            let twiddles = Twiddles::new(log_n, shift);
726
727            for top in 0..=log_n {
728                let label = format!("log_n={log_n} width={width} shift={shift_bits:#x} top={top}");
729
730                // Forward: the range is the tail of the network, so it runs widest stage first.
731                let mut expected = coeffs.clone();
732                stages_one_at_a_time::<F, false>(&mut expected.values, width, top, &twiddles);
733
734                let mut blocked = coeffs.clone();
735                run::<F, false>(&mut blocked.values, width, log_n, top, &twiddles, schedule);
736                assert_eq!(blocked, expected, "forward {label}");
737
738                // The same range undone puts the coefficients back, whatever the blocking.
739                run::<F, true>(&mut blocked.values, width, log_n, top, &twiddles, schedule);
740                assert_eq!(blocked, coeffs, "round trip {label}");
741
742                // Inverse: the range is the head of the network, so it runs narrowest first.
743                let mut expected = coeffs.clone();
744                stages_one_at_a_time::<F, true>(&mut expected.values, width, top, &twiddles);
745
746                let mut blocked = coeffs.clone();
747                run::<F, true>(&mut blocked.values, width, log_n, top, &twiddles, schedule);
748                assert_eq!(blocked, expected, "inverse {label}");
749            }
750        }
751    }
752
753    /// The blocked schedule and the plain one agree element for element, in both directions.
754    fn check_schedules_agree<F: ButterflyField>(log_n: usize, width: usize, schedule: &Schedule) {
755        let coeffs = matrix::<F>(log_n, width, 3);
756        for shift_bits in SHIFTS {
757            let shift = sample::<F>(shift_bits);
758            let twiddles = Twiddles::new(log_n, shift);
759            let label = format!("log_n={log_n} width={width} shift={shift_bits:#x}");
760
761            // Forward: the reference runs stage by stage over the whole matrix.
762            let mut expected = coeffs.clone();
763            stage_by_stage::<F, false>(&mut expected, &twiddles);
764
765            let mut blocked = coeffs.clone();
766            run::<F, false>(
767                &mut blocked.values,
768                width,
769                log_n,
770                log_n,
771                &twiddles,
772                schedule,
773            );
774            assert_eq!(blocked, expected, "forward {label}");
775
776            // Inverse: same comparison with the stage order reversed.
777            let mut expected = coeffs.clone();
778            stage_by_stage::<F, true>(&mut expected, &twiddles);
779
780            let mut blocked = coeffs.clone();
781            run::<F, true>(
782                &mut blocked.values,
783                width,
784                log_n,
785                log_n,
786                &twiddles,
787                schedule,
788            );
789            assert_eq!(blocked, expected, "inverse {label}");
790        }
791    }
792
793    /// `LchNtt` agrees with the oracle on a random matrix and a random coset.
794    fn check_matches_naive<F: ButterflyField>(log_n: usize, width: usize, seed: u64, shift: u64) {
795        let coeffs = matrix::<F>(log_n, width, seed);
796        let shift = sample::<F>(shift);
797
798        let fast = LchNtt::<F>::default().shifted_ntt_batch(coeffs.clone(), shift);
799        let slow = NaiveAdditiveNtt::<F>::default().shifted_ntt_batch(coeffs, shift);
800        assert_eq!(fast, slow);
801    }
802
803    /// The twiddle a block is handed, walked from the block before it and checked against the
804    /// twiddle that block's own index defines.
805    ///
806    /// Every schedule below carries a twiddle forward across blocks instead of computing it.
807    /// An error in the increment table is therefore a drift that starts at one block index.
808    /// So the starts below seed the walk at several block indices per stage, not at zero alone.
809    fn check_the_twiddle_walk<F: ButterflyField>(log_n: usize, shift_bits: u64) {
810        let shift = sample::<F>(shift_bits);
811        let twiddles = Twiddles::new(log_n, shift);
812
813        for stage in 0..log_n {
814            // Stage `stage` pairs rows `2^stage` apart, so a block spans `2^(stage + 1)` rows.
815            let blocks = 1usize << (log_n - 1 - stage);
816            for start in [0, 1, 3, 63, blocks.saturating_sub(5)] {
817                if start >= blocks {
818                    continue;
819                }
820                let mut t = twiddles.at(stage, start);
821                for block in start..blocks.min(start + 5) {
822                    // The independent formula: `W_stage(shift)` plus the block's domain point.
823                    let expected =
824                        subspace_polynomial::<F>(stage, shift) + domain_point::<F>(block << 1);
825                    assert_eq!(t, expected, "stage={stage} block={block}");
826                    if block + 1 < blocks {
827                        t += twiddles.step(block + 1);
828                    }
829                }
830            }
831        }
832    }
833
834    #[test]
835    fn incremental_twiddles_match_independent_domain_points() {
836        for shift_bits in SHIFTS {
837            check_the_twiddle_walk::<BinaryField32>(16, shift_bits);
838            check_the_twiddle_walk::<BinaryField64>(16, shift_bits);
839            check_the_twiddle_walk::<BinaryField128>(16, shift_bits);
840            check_the_twiddle_walk::<Ghash128>(16, shift_bits);
841        }
842    }
843
844    /// The transform agrees with the serial walk, element for element, in both directions.
845    fn check_walk_agrees<F: ButterflyField>(log_n: usize, width: usize, shift_bits: u64) {
846        let coeffs = matrix::<F>(log_n, width, 41);
847        let shift = sample::<F>(shift_bits);
848        let ntt = LchNtt::<F>::default();
849        let label = format!("log_n={log_n} width={width} shift={shift_bits:#x}");
850
851        assert_eq!(
852            ntt.shifted_ntt_batch(coeffs.clone(), shift),
853            twiddle_walk_ntt::<F>(coeffs.clone(), shift),
854            "ntt {label}"
855        );
856        assert_eq!(
857            ntt.shifted_intt_batch(coeffs.clone(), shift),
858            twiddle_walk_intt::<F>(coeffs, shift),
859            "intt {label}"
860        );
861    }
862
863    /// The blocked schedule of a named worker count, against the same independent walk.
864    ///
865    /// [`transform`] picks its own schedule from the shape and the thread count, so what it
866    /// covers moves with the machine. This drives `run` directly instead: the walk then pins the
867    /// blocked path whatever the ambient thread count is.
868    fn check_the_blocked_walk_agrees<F: ButterflyField>(
869        log_n: usize,
870        width: usize,
871        shift_bits: u64,
872        workers: &[usize],
873    ) {
874        let coeffs = matrix::<F>(log_n, width, 41);
875        let shift = sample::<F>(shift_bits);
876        let twiddles = Twiddles::new(log_n, shift);
877        let walked = twiddle_walk_ntt::<F>(coeffs.clone(), shift);
878
879        for &workers in workers {
880            let schedule = Schedule::for_workers::<F>(width, log_n, workers);
881            let label =
882                format!("log_n={log_n} width={width} shift={shift_bits:#x} workers={workers}");
883
884            let mut blocked = coeffs.clone();
885            run::<F, false>(
886                &mut blocked.values,
887                width,
888                log_n,
889                log_n,
890                &twiddles,
891                &schedule,
892            );
893            assert_eq!(blocked, walked, "ntt {label}");
894
895            // Undoing the walk's own codeword holds the inverse schedule to the same twiddles.
896            let mut blocked = walked.clone();
897            run::<F, true>(
898                &mut blocked.values,
899                width,
900                log_n,
901                log_n,
902                &twiddles,
903                &schedule,
904            );
905            assert_eq!(blocked, coeffs, "intt {label}");
906        }
907    }
908
909    /// `LchNtt` agrees with the oracle in both directions, on the subspace and on a coset.
910    fn check_oracle_agrees<F: ButterflyField>(log_n: usize, width: usize, shift_bits: u64) {
911        let coeffs = matrix::<F>(log_n, width, 53);
912        let shift = sample::<F>(shift_bits);
913        let fast = LchNtt::<F>::default();
914        let slow = NaiveAdditiveNtt::<F>::default();
915        let label = format!("log_n={log_n} width={width} shift={shift_bits:#x}");
916
917        assert_eq!(
918            fast.shifted_ntt_batch(coeffs.clone(), shift),
919            slow.shifted_ntt_batch(coeffs.clone(), shift),
920            "ntt {label}"
921        );
922        assert_eq!(
923            fast.shifted_intt_batch(coeffs.clone(), shift),
924            slow.shifted_intt_batch(coeffs, shift),
925            "intt {label}"
926        );
927    }
928
929    proptest! {
930        #![proptest_config(ProptestConfig::with_cases(32))]
931
932        #[test]
933        fn ghash_packing_matches_naive_and_round_trips(
934            log_n in 0usize..=7,
935            width in 1usize..=9,
936            seed: u64,
937            shift: u64,
938        ) {
939            // Odd widths force scalar tails alongside SIMD prefixes.
940            check_matches_naive::<Ghash128>(log_n, width, seed, shift);
941            let coeffs = matrix::<Ghash128>(log_n, width, seed);
942            let shift = sample::<Ghash128>(shift);
943            let ntt = LchNtt::<Ghash128>::default();
944            let transformed = ntt.shifted_ntt_batch(coeffs.clone(), shift);
945            prop_assert_eq!(ntt.shifted_intt_batch(transformed, shift), coeffs);
946        }
947
948        #[test]
949        fn lch_matches_naive_at_8_bits(
950            log_n in 0usize..=8,
951            width in 1usize..=5,
952            seed in any::<u64>(),
953            shift in any::<u64>(),
954        ) {
955            check_matches_naive::<BinaryField8>(log_n, width, seed, shift);
956        }
957
958        #[test]
959        fn lch_matches_naive_at_16_bits(
960            log_n in 0usize..=8,
961            width in 1usize..=5,
962            seed in any::<u64>(),
963            shift in any::<u64>(),
964        ) {
965            check_matches_naive::<BinaryField16>(log_n, width, seed, shift);
966        }
967
968        #[test]
969        fn lch_matches_naive_at_32_bits(
970            log_n in 0usize..=8,
971            width in 1usize..=5,
972            seed in any::<u64>(),
973            shift in any::<u64>(),
974        ) {
975            check_matches_naive::<BinaryField32>(log_n, width, seed, shift);
976        }
977
978        #[test]
979        fn lch_matches_naive_at_64_bits(
980            log_n in 0usize..=8,
981            width in 1usize..=5,
982            seed in any::<u64>(),
983            shift in any::<u64>(),
984        ) {
985            check_matches_naive::<BinaryField64>(log_n, width, seed, shift);
986        }
987
988        #[test]
989        fn lch_matches_naive_at_128_bits(
990            log_n in 0usize..=8,
991            width in 1usize..=5,
992            seed in any::<u64>(),
993            shift in any::<u64>(),
994        ) {
995            check_matches_naive::<BinaryField128>(log_n, width, seed, shift);
996        }
997
998        #[test]
999        fn lch_round_trips(
1000            log_n in 0usize..=10,
1001            width in 1usize..=3,
1002            seed in any::<u64>(),
1003            shift in any::<u64>(),
1004        ) {
1005            let coeffs = matrix::<BinaryField16>(log_n, width, seed);
1006            let shift = sample::<BinaryField16>(shift);
1007            let ntt = LchNtt::<BinaryField16>::default();
1008            let evals = ntt.shifted_ntt_batch(coeffs.clone(), shift);
1009            prop_assert_eq!(ntt.shifted_intt_batch(evals, shift), coeffs);
1010        }
1011
1012        /// `LchNtt`'s low-degree extension agrees with the oracle's, on a coset too, and the
1013        /// input rows reappear as the prefix: the correspondence Phase 3 folds along.
1014        #[test]
1015        fn lch_lde_matches_naive(
1016            log_n in 0usize..=6,
1017            added in 0usize..=3,
1018            width in 1usize..=3,
1019            seed in any::<u64>(),
1020            shift in any::<u64>(),
1021        ) {
1022            let coeffs = matrix::<BinaryField16>(log_n, width, seed);
1023            let shift = sample::<BinaryField16>(shift);
1024
1025            let lde = LchNtt::<BinaryField16>::default()
1026                .shifted_lde_batch(coeffs.clone(), added, shift);
1027            let naive = NaiveAdditiveNtt::<BinaryField16>::default()
1028                .shifted_lde_batch(coeffs.clone(), added, shift);
1029            prop_assert_eq!(&lde, &naive);
1030            prop_assert_eq!(&lde.values[..coeffs.values.len()], &coeffs.values[..]);
1031        }
1032    }
1033
1034    proptest! {
1035        // A blocked height is a whole matrix per case, so this block draws fewer of them.
1036        #![proptest_config(ProptestConfig::with_cases(8))]
1037
1038        /// The two directions invert each other at heights the schedule blocks.
1039        #[test]
1040        fn lch_round_trips_past_the_blocking_threshold(
1041            log_n in 11usize..=14,
1042            width in 1usize..=4,
1043            seed in any::<u64>(),
1044            shift in any::<u64>(),
1045        ) {
1046            // Fixture state: the two extreme element sizes, hence the two extreme tile shapes.
1047            // A 4-byte element gets the deepest contiguous tile of the four levels and a
1048            // 16-byte one the shallowest, so between them the drawn heights land on either side
1049            // of a boundary.
1050            let coeffs = matrix::<BinaryField32>(log_n, width, seed);
1051            let ntt = LchNtt::<BinaryField32>::default();
1052            let shift32 = sample::<BinaryField32>(shift);
1053            let evals = ntt.shifted_ntt_batch(coeffs.clone(), shift32);
1054            prop_assert_eq!(ntt.shifted_intt_batch(evals, shift32), coeffs);
1055
1056            let coeffs = matrix::<Ghash128>(log_n, width, seed);
1057            let ntt = LchNtt::<Ghash128>::default();
1058            let shift128 = sample::<Ghash128>(shift);
1059            let evals = ntt.shifted_ntt_batch(coeffs.clone(), shift128);
1060            prop_assert_eq!(ntt.shifted_intt_batch(evals, shift128), coeffs);
1061        }
1062    }
1063
1064    proptest! {
1065        #![proptest_config(ProptestConfig::with_cases(32))]
1066
1067        /// Random padded shapes, against running the skipped layers as ordinary butterflies.
1068        #[test]
1069        fn random_padded_shapes_match_the_full_transform(
1070            log_message in 0usize..=7,
1071            width in 1usize..=5,
1072            log_inv_rate in 0usize..=3,
1073            seed in any::<u64>(),
1074        ) {
1075            let message = matrix::<BinaryField128>(log_message, width, seed);
1076            let mut padded = message.values;
1077            padded.resize(padded.len() << log_inv_rate, BinaryField128::ZERO);
1078            let padded = RowMajorMatrix::new(padded, width);
1079
1080            let ntt = LchNtt::<BinaryField128>::default();
1081            let expected = ntt.ntt_batch(padded.clone());
1082            prop_assert_eq!(ntt.ntt_batch_padded(padded, log_inv_rate), expected);
1083        }
1084    }
1085
1086    #[test]
1087    fn lch_matches_a_twiddle_walk_across_several_tasks() {
1088        // Fixture state: a height whose stages take more than one butterfly task, so a task
1089        // seeds its twiddle at a block index of its own rather than at zero.
1090        //
1091        // The oracle tests all sit below this height, and `lch_round_trips` is blind to the
1092        // schedule: both directions read the same twiddles, so they invert each other whatever
1093        // those twiddles are. Only a comparison against an independent walk pins them.
1094        const LOG_N: usize = 12;
1095        let ntt = LchNtt::<BinaryField128>::default();
1096        for width in [1usize, 3] {
1097            for shift_bits in [0u64, 0x1234_5678_9abc_def0] {
1098                let coeffs = matrix::<BinaryField128>(LOG_N, width, 5);
1099                let shift = sample::<BinaryField128>(shift_bits);
1100
1101                let walked = twiddle_walk_ntt::<BinaryField128>(coeffs.clone(), shift);
1102                assert_eq!(
1103                    ntt.shifted_ntt_batch(coeffs.clone(), shift),
1104                    walked,
1105                    "ntt width={width} shift={shift_bits:#x}"
1106                );
1107                // The inverse has its own copy of the schedule.
1108                //
1109                // Undoing a codeword the walk produced holds that copy to the same twiddles.
1110                assert_eq!(
1111                    ntt.shifted_intt_batch(walked, shift),
1112                    coeffs,
1113                    "intt width={width} shift={shift_bits:#x}"
1114                );
1115            }
1116        }
1117    }
1118
1119    #[test]
1120    fn blocked_schedule_matches_the_plain_one() {
1121        // Invariant: the blocked schedule is a pure reordering of the plain one, so it
1122        // reproduces it bit for bit wherever the tile boundaries fall differently. A round trip
1123        // cannot see this: both directions read the same twiddle, so they invert each other
1124        // whatever index that twiddle came from.
1125        //
1126        // The tile shapes here are far smaller than a cache, which is what lets a test-sized
1127        // matrix reach the branches a production-sized one does. They also reach `run` whole:
1128        // the worker count reshapes a schedule inside `Schedule::for_workers`, so these
1129        // fixtures run the same stages at any thread count.
1130
1131        // Fixture state: rows per contiguous tile, staged rows per group, rows per staged row.
1132        let shape = |tile: usize, staged: usize, slab: usize| Schedule {
1133            log_tile_rows: tile,
1134            log_staged_rows: staged,
1135            log_slab_rows: slab,
1136        };
1137
1138        for width in WIDTHS {
1139            // A three-stage tile with two-stage groups above it.
1140            //
1141            // Heights 2 and 3 stay inside a single tile; 4 leaves one stage over it, which runs
1142            // as a plain pass; 5 is exactly one fused group.
1143            //
1144            // 6 is a fused group and a plain pass, 7 two fused groups, and 8 two of them with a
1145            // plain pass on top.
1146            for log_n in [2usize, 3, 4, 5, 6, 7, 8] {
1147                let schedule = shape(3, 2, 0);
1148                check_schedules_agree::<BinaryField32>(log_n, width, &schedule);
1149                check_schedules_agree::<BinaryField64>(log_n, width, &schedule);
1150                check_schedules_agree::<BinaryField128>(log_n, width, &schedule);
1151                check_schedules_agree::<Ghash128>(log_n, width, &schedule);
1152            }
1153
1154            // Groups deeper than the stages left over them, so the top group is clipped: at 7 to
1155            // a single stage, which is a plain pass, and at 8 to a fused group of two.
1156            check_schedules_agree::<BinaryField128>(7, width, &shape(2, 4, 0));
1157            check_schedules_agree::<BinaryField128>(8, width, &shape(2, 4, 0));
1158            // A single-stage group everywhere, which is the plain pass under another name.
1159            check_schedules_agree::<BinaryField128>(7, width, &shape(2, 1, 0));
1160            // No tile at all, so every stage belongs to a group.
1161            check_schedules_agree::<BinaryField128>(6, width, &shape(0, 3, 0));
1162            // Staged rows holding a run of matrix rows each, as narrow rows call for.
1163            check_schedules_agree::<BinaryField32>(8, width, &shape(4, 3, 2));
1164            check_schedules_agree::<BinaryField32>(9, width, &shape(3, 2, 1));
1165        }
1166    }
1167
1168    #[test]
1169    fn production_schedule_matches_the_plain_one() {
1170        // Fixture state: the production tile shapes, past the height where blocking switches
1171        // on. The shapes come from cache budgets, so the heights that clear them differ by
1172        // shape, and each pairing below leaves at least one group above the contiguous tile.
1173        //
1174        // The worker count is named rather than read: a lone worker takes the deepest tile the
1175        // budget allows, and 32 workers the shallowest the clamp leaves.
1176        for workers in [1usize, 32] {
1177            for (log_n, width) in [(14usize, 3usize), (15, 3), (13, 16)] {
1178                let schedule = Schedule::for_workers::<BinaryField32>(width, log_n, workers);
1179                assert!(schedule.log_tile_rows < log_n, "no group above the tile");
1180                check_schedules_agree::<BinaryField32>(log_n, width, &schedule);
1181            }
1182            for (log_n, width) in [(11usize, 16usize), (12, 16), (13, 5)] {
1183                let schedule = Schedule::for_workers::<BinaryField128>(width, log_n, workers);
1184                assert!(schedule.log_tile_rows < log_n, "no group above the tile");
1185                check_schedules_agree::<BinaryField128>(log_n, width, &schedule);
1186                check_schedules_agree::<Ghash128>(log_n, width, &schedule);
1187            }
1188        }
1189    }
1190
1191    #[test]
1192    fn a_partial_stage_range_matches_the_plain_passes() {
1193        // Invariant: a schedule asked for part of the network runs those stages and no others.
1194        //
1195        // The subfield split is the only caller that stops short, and it stops forward.
1196        //
1197        // Below the staging worker count no partial range reaches a fused group at all.
1198        // So the shapes below are named rather than read off the machine.
1199        //
1200        // Fixture state: rows per contiguous tile, staged rows per group, rows per staged row.
1201        let shape = |tile: usize, staged: usize, slab: usize| Schedule {
1202            log_tile_rows: tile,
1203            log_staged_rows: staged,
1204            log_slab_rows: slab,
1205        };
1206
1207        for width in [1usize, 3, 16] {
1208            // A three-stage tile with two-stage groups above it.
1209            // The sweep then ends inside the tile, at its edge, inside a group, and past one.
1210            check_every_stage_range_agrees::<BinaryField128>(8, width, &shape(3, 2, 0));
1211
1212            // Groups deeper than the stages left over them, so the top group is always clipped.
1213            check_every_stage_range_agrees::<BinaryField128>(7, width, &shape(2, 4, 0));
1214
1215            // No tile at all, so every stage of the range belongs to a group.
1216            check_every_stage_range_agrees::<BinaryField128>(6, width, &shape(0, 3, 0));
1217
1218            // Staged rows holding a run of matrix rows each, as narrow rows call for.
1219            check_every_stage_range_agrees::<BinaryField32>(8, width, &shape(4, 3, 2));
1220        }
1221
1222        // The production shapes at a named worker count, where a cache budget does the grouping.
1223        for workers in [1usize, 32] {
1224            let schedule = Schedule::for_workers::<BinaryField128>(16, 11, workers);
1225            assert!(schedule.log_tile_rows < 11, "no group above the tile");
1226            check_every_stage_range_agrees::<BinaryField128>(11, 16, &schedule);
1227        }
1228    }
1229
1230    /// One padded shape: skipping the layers that cross the padding against running them.
1231    fn check_the_padded_transform_agrees<F: ButterflyField>(
1232        log_message: usize,
1233        width: usize,
1234        log_inv_rate: usize,
1235    ) {
1236        let message = matrix::<F>(log_message, width, 59);
1237
1238        // The reference pads the coefficients and transforms every layer of the result.
1239        let mut padded = message.values;
1240        padded.resize(padded.len() << log_inv_rate, F::ZERO);
1241        let padded = RowMajorMatrix::new(padded, width);
1242
1243        let ntt = LchNtt::<F>::default();
1244        let expected = ntt.ntt_batch(padded.clone());
1245        assert_eq!(
1246            ntt.ntt_batch_padded(padded, log_inv_rate),
1247            expected,
1248            "log_message={log_message} width={width} rate={log_inv_rate}"
1249        );
1250    }
1251
1252    #[test]
1253    fn the_padded_transform_matches_the_full_one() {
1254        // Invariant: a layer whose high side is all zero copies, so skipping it changes nothing.
1255        //
1256        //     rate 0   one coset, and the skip has no layer to skip
1257        //     rate 3   eight cosets, seven of them a copy of the first
1258        for width in WIDTHS {
1259            for log_message in 0..=5 {
1260                for log_inv_rate in 0..=3 {
1261                    check_the_padded_transform_agrees::<BinaryField32>(
1262                        log_message,
1263                        width,
1264                        log_inv_rate,
1265                    );
1266                    check_the_padded_transform_agrees::<BinaryField128>(
1267                        log_message,
1268                        width,
1269                        log_inv_rate,
1270                    );
1271                    check_the_padded_transform_agrees::<Ghash128>(log_message, width, log_inv_rate);
1272                }
1273            }
1274        }
1275    }
1276
1277    #[test]
1278    fn the_padded_transform_matches_the_reference_oracle() {
1279        // The oracle evaluates the novel basis straight from its product definition.
1280        // So it pins the coset split to that basis, not to another split of the same network.
1281        for width in [1usize, 3] {
1282            for log_message in 0..=4 {
1283                for log_inv_rate in 0..=2 {
1284                    let message = matrix::<BinaryField64>(log_message, width, 61);
1285                    let mut padded = message.values;
1286                    padded.resize(padded.len() << log_inv_rate, BinaryField64::ZERO);
1287                    let padded = RowMajorMatrix::new(padded, width);
1288
1289                    let expected =
1290                        NaiveAdditiveNtt::<BinaryField64>::default().ntt_batch(padded.clone());
1291                    let actual =
1292                        LchNtt::<BinaryField64>::default().ntt_batch_padded(padded, log_inv_rate);
1293                    let label = format!("log_message={log_message} width={width}");
1294                    assert_eq!(actual, expected, "{label} rate={log_inv_rate}");
1295                }
1296            }
1297        }
1298    }
1299
1300    #[test]
1301    #[should_panic = "padding exceeds matrix height"]
1302    fn the_padded_transform_rejects_padding_past_the_height() {
1303        // A 2^2-row matrix cannot have been padded from a fraction of a row.
1304        let mat = matrix::<BinaryField32>(2, 4, 0);
1305        let _ = LchNtt::<BinaryField32>::default().ntt_batch_padded(mat, 3);
1306    }
1307
1308    #[test]
1309    #[should_panic = "domain exceeds field dimension"]
1310    fn the_padded_transform_rejects_a_domain_past_the_field_dimension() {
1311        // A 2^9-row domain asks for nine Cantor basis vectors, and a byte level has eight.
1312        let mat = matrix::<BinaryField8>(9, 1, 0);
1313        let _ = LchNtt::<BinaryField8>::default().ntt_batch_padded(mat, 1);
1314    }
1315
1316    #[test]
1317    fn the_contiguous_tile_leaves_one_for_every_worker() {
1318        // Invariant: a contiguous tile is a task, so the schedule keeps one per worker wherever
1319        // the height allows, and is otherwise as deep as the byte budget permits.
1320        //
1321        // No comparison against the plain schedule can see this: the clamp only moves where a
1322        // stage runs, never what it computes. Replacing it with `.min(log_n)` leaves the rest of
1323        // this module passing at any thread count.
1324        for workers in [1usize, 2, 3, 4, 14, 16, 32, 4096] {
1325            for log_n in 0..=24usize {
1326                for width in [1usize, 3, 16, 64, 1 << 14] {
1327                    let tile =
1328                        Schedule::for_workers::<BinaryField32>(width, log_n, workers).log_tile_rows;
1329                    assert!(tile <= log_n, "a tile taller than the matrix");
1330                    let tiles = 1usize << (log_n - tile);
1331                    assert!(
1332                        tiles >= workers.min(1 << log_n),
1333                        "workers={workers} log_n={log_n} width={width}: {tiles} tiles"
1334                    );
1335
1336                    // And no shallower than it has to be: one more row per tile would either
1337                    // halve the tile count below the worker count or overrun the budget.
1338                    let row_bytes = core::mem::size_of::<BinaryField32>() * width;
1339                    let deeper_fits = (row_bytes << (tile + 1)) <= DEEP_TILE_BYTES;
1340                    assert!(
1341                        tile == log_n || !deeper_fits || tiles / 2 < workers,
1342                        "workers={workers} log_n={log_n} width={width}: tile {tile} is short"
1343                    );
1344                }
1345            }
1346        }
1347
1348        // One shape where the clamp bites: a 4-byte row of one element fills a tile at 2^15
1349        // rows, so a lone worker takes all of a 2^16-row matrix in two tiles, and 32 workers
1350        // give up four stages of tile depth to have one each.
1351        assert_eq!(
1352            Schedule::for_workers::<BinaryField32>(1, 16, 1).log_tile_rows,
1353            15
1354        );
1355        assert_eq!(
1356            Schedule::for_workers::<BinaryField32>(1, 16, 32).log_tile_rows,
1357            11
1358        );
1359    }
1360
1361    #[test]
1362    fn staging_waits_for_enough_workers() {
1363        // Invariant: below `STAGED_WORKERS` the schedule leaves every stage above the contiguous
1364        // tile a plain pass, however much staging the byte budget would have paid for.
1365        //
1366        // The tile itself is unaffected at these counts, so they differ in the groups alone.
1367        for workers in [1usize, STAGED_WORKERS - 1, STAGED_WORKERS] {
1368            let schedule = Schedule::for_workers::<Ghash128>(16, 20, workers);
1369            assert_eq!(
1370                schedule.log_tile_rows, 9,
1371                "a 256-byte row fills the tile budget at 2^9 rows"
1372            );
1373            assert_eq!(
1374                schedule.log_staged_rows >= MIN_FUSED_STAGES,
1375                workers >= STAGED_WORKERS,
1376                "workers={workers}"
1377            );
1378        }
1379    }
1380
1381    #[test]
1382    fn a_staging_group_stages_every_row_exactly_once() {
1383        // Invariant: the map a staging task walks is a bijection onto the matrix rows, and the
1384        // butterfly block it hands the tile pass is the block those rows really sit in.
1385        //
1386        // Both hold at runtime under `debug_assert` alone, and the second only indirectly. So
1387        // the model below walks every shape the schedule can produce, against the definitions
1388        // rather than against another copy of the map.
1389        let mut shapes = 0;
1390        for log_n in 1..=10usize {
1391            for top in 0..log_n {
1392                // A group is at least one stage, and a staged run may not straddle the butterfly
1393                // of its narrowest stage: `log_slab + depth <= top + 1`.
1394                for depth in 1..=top + 1 {
1395                    for log_slab in 0..=top + 1 - depth {
1396                        shapes += 1;
1397                        let map = staged_runs(1, top, depth, log_slab);
1398                        let mut seen = vec![false; 1 << log_n];
1399                        for task in 0..1usize << (log_n - depth - log_slab) {
1400                            for k in 0..1usize << depth {
1401                                let first = map.run_index(task, k) << log_slab;
1402
1403                                // A staged row is the run of `2^log_slab` rows from there.
1404                                for row in first..first + (1 << log_slab) {
1405                                    let hit =
1406                                        seen.get_mut(row).expect("the walk leaves the matrix");
1407                                    assert!(!*hit, "row {row} staged twice");
1408                                    *hit = true;
1409                                }
1410
1411                                // Stage `top - s` puts a row in block `row >> (top - s + 1)`.
1412                                //
1413                                // `tile_stages` is told `map.block(task)` and adds the block
1414                                // index a tile of `2^depth` rows walks on its own.
1415                                for s in 0..depth {
1416                                    assert_eq!(
1417                                        first >> (top - s + 1),
1418                                        (map.block(task) << s) + (k >> (depth - s)),
1419                                        "log_n={log_n} top={top} depth={depth} \
1420                                         slab={log_slab} task={task} k={k} s={s}"
1421                                    );
1422                                }
1423                            }
1424                        }
1425                        assert!(seen.iter().all(|&hit| hit), "a row was left unstaged");
1426                    }
1427                }
1428            }
1429        }
1430        assert_eq!(shapes, 715, "the model stopped covering every shape");
1431    }
1432
1433    /// The transform against an independent serial walk, over a range of heights.
1434    /// Every width and both shifts, in both directions.
1435    ///
1436    /// The walk shares no code with the transform below the field arithmetic.
1437    /// It recomputes `W_j(shift)` from the recurrence and every block twiddle from its index.
1438    /// So it pins the blocked schedule's twiddles and not merely its memory order.
1439    fn sweep_against_the_walk<F: ButterflyField>(heights: core::ops::RangeInclusive<usize>) {
1440        for width in WIDTHS {
1441            for log_n in heights.clone() {
1442                for shift_bits in SHIFTS {
1443                    check_walk_agrees::<F>(log_n, width, shift_bits);
1444                }
1445            }
1446        }
1447    }
1448
1449    #[test]
1450    fn the_schedule_matches_an_independent_walk_at_32_bits() {
1451        sweep_against_the_walk::<BinaryField32>(LOG_HEIGHTS);
1452    }
1453
1454    #[test]
1455    fn the_schedule_matches_an_independent_walk_at_64_bits() {
1456        sweep_against_the_walk::<BinaryField64>(LOG_HEIGHTS);
1457    }
1458
1459    #[test]
1460    fn the_schedule_matches_an_independent_walk_at_128_bits() {
1461        sweep_against_the_walk::<BinaryField128>(LOG_HEIGHTS);
1462    }
1463
1464    #[test]
1465    fn the_schedule_matches_an_independent_walk_in_the_ghash_basis() {
1466        sweep_against_the_walk::<Ghash128>(LOG_HEIGHTS);
1467    }
1468
1469    #[test]
1470    #[ignore = "serial oracle over every width and level up to 2^14; run from heavy CI"]
1471    fn the_schedule_matches_an_independent_walk_at_every_blocked_shape() {
1472        // Invariant: the same sweep, carried to the first height that blocks at every shape.
1473        //
1474        // Narrow rows included, so no tile boundary of any level falls between two heights.
1475        sweep_against_the_walk::<BinaryField32>(HEAVY_LOG_HEIGHTS);
1476        sweep_against_the_walk::<BinaryField64>(HEAVY_LOG_HEIGHTS);
1477        sweep_against_the_walk::<BinaryField128>(HEAVY_LOG_HEIGHTS);
1478        sweep_against_the_walk::<Ghash128>(HEAVY_LOG_HEIGHTS);
1479    }
1480
1481    /// Every width, at the three heights around its contiguous tile boundary: one budget
1482    /// exactly, one stage above it, and two stages above it, which a staging tile fuses.
1483    ///
1484    /// The boundary moves with the element size and the width, so it is read back out of the
1485    /// schedule rather than written down.
1486    fn sweep_across_the_tile_boundaries<F: ButterflyField>() {
1487        for width in BOUNDARY_WIDTHS {
1488            // Neither the height nor the worker count binds at a height no level can reach, so
1489            // this reads the raw budget.
1490            let tile = Schedule::for_workers::<F>(width, usize::BITS as usize, 1).log_tile_rows;
1491            for log_n in [tile, tile + 1, tile + 2] {
1492                for shift_bits in SHIFTS {
1493                    check_walk_agrees::<F>(log_n, width, shift_bits);
1494                }
1495            }
1496
1497            // The height above the boundary again, on the blocked schedule of a named worker
1498            // count, since the one `transform` picks depends on the machine running the test.
1499            for shift_bits in SHIFTS {
1500                check_the_blocked_walk_agrees::<F>(tile + 2, width, shift_bits, &[1, 32]);
1501            }
1502        }
1503    }
1504
1505    #[test]
1506    fn every_tile_boundary_is_crossed_at_32_bits() {
1507        sweep_across_the_tile_boundaries::<BinaryField32>();
1508    }
1509
1510    #[test]
1511    fn every_tile_boundary_is_crossed_at_64_bits() {
1512        sweep_across_the_tile_boundaries::<BinaryField64>();
1513    }
1514
1515    #[test]
1516    fn every_tile_boundary_is_crossed_at_128_bits() {
1517        sweep_across_the_tile_boundaries::<BinaryField128>();
1518    }
1519
1520    #[test]
1521    fn every_tile_boundary_is_crossed_in_the_ghash_basis() {
1522        sweep_across_the_tile_boundaries::<Ghash128>();
1523    }
1524
1525    /// The narrow shapes whose staging tile stages a run of rows rather than a single row.
1526    ///
1527    /// A row shorter than a cache line turns one staged row into a run of matrix rows. The
1528    /// boundary sweep above already compares these shapes element for element, so this pins
1529    /// only that they really take the run branch.
1530    fn check_a_staged_run_of_rows<F: ButterflyField>(log_n: usize) {
1531        // The heights above come from the deepest tile the budget allows, which is the one a
1532        // lone worker takes.
1533        let alone = Schedule::for_workers::<F>(1, log_n, 1);
1534        assert_eq!(alone.log_tile_rows + MIN_FUSED_STAGES, log_n);
1535        assert!(alone.log_slab_rows > 0, "the staged row is a single row");
1536
1537        // The clamp then moves the tile but not the run, so the group is still staged in runs.
1538        let shared = Schedule::for_workers::<F>(1, log_n, STAGED_WORKERS);
1539        assert!(
1540            shared.log_staged_rows >= MIN_FUSED_STAGES,
1541            "nothing is staged"
1542        );
1543        assert!(shared.log_slab_rows > 0, "the staged row is a single row");
1544    }
1545
1546    #[test]
1547    fn narrow_rows_are_staged_in_runs() {
1548        check_a_staged_run_of_rows::<BinaryField32>(17);
1549        check_a_staged_run_of_rows::<BinaryField64>(16);
1550        check_a_staged_run_of_rows::<BinaryField128>(15);
1551        check_a_staged_run_of_rows::<Ghash128>(15);
1552    }
1553
1554    #[test]
1555    fn the_transform_matches_the_reference_oracle_at_every_width() {
1556        // Invariant: the transform is the same map as the reference oracle, at every width.
1557        //
1558        // The oracle evaluates `Σ_i d_i · X_i(x)` straight from the product definition and
1559        // depends on none of D8's identities, so it pins the transform to the novel basis
1560        // rather than to another consistent network.
1561        //
1562        // It costs `O(n² · width)`, which is what caps the heights below; the walk sweeps carry
1563        // the taller shapes, and together they reach every branch.
1564        for width in WIDTHS {
1565            for log_n in 0..=6 {
1566                for shift_bits in SHIFTS {
1567                    check_oracle_agrees::<BinaryField32>(log_n, width, shift_bits);
1568                    check_oracle_agrees::<BinaryField64>(log_n, width, shift_bits);
1569                    check_oracle_agrees::<BinaryField128>(log_n, width, shift_bits);
1570                    check_oracle_agrees::<Ghash128>(log_n, width, shift_bits);
1571                }
1572            }
1573        }
1574    }
1575
1576    #[test]
1577    fn a_row_wider_than_the_budgets_degrades_to_plain_passes() {
1578        // Invariant: a row wider than both byte budgets leaves no room to block.
1579        //
1580        // The schedule degrades to plain passes, rather than to a tile of no rows.
1581        const WIDTH: usize = 16384;
1582        let schedule = Schedule::for_workers::<BinaryField128>(WIDTH, 4, 32);
1583        assert_eq!(schedule.log_tile_rows, 0);
1584        assert_eq!(schedule.log_slab_rows, 0);
1585        assert_eq!(schedule.log_staged_rows, 0);
1586        check_schedules_agree::<BinaryField128>(2, WIDTH, &schedule);
1587    }
1588
1589    #[test]
1590    #[should_panic]
1591    fn shifted_ntt_batch_rejects_l_past_the_bit_width() {
1592        // An index of `S_ℓ` past the bit width of `F` asks for a Cantor basis vector.
1593        //
1594        // This level does not have one.
1595        // `BinaryField8` has `2^LOG_BITS = 8` Cantor basis vectors, indices `0..8`.
1596        let coeffs = matrix::<BinaryField8>((1 << BinaryField8::LOG_BITS) + 1, 1, 0);
1597        let _ = LchNtt::<BinaryField8>::default().ntt_batch(coeffs);
1598    }
1599
1600    /// A height that is not a power of two has no well-defined `ℓ`.
1601    #[test]
1602    #[should_panic]
1603    fn shifted_ntt_batch_rejects_a_non_power_of_two_height() {
1604        let coeffs = RowMajorMatrix::new(vec![sample::<BinaryField8>(0); 3], 1);
1605        let _ = LchNtt::<BinaryField8>::default().ntt_batch(coeffs);
1606    }
1607
1608    /// The transform is `F_2`-linear in the coefficient vector.
1609    #[test]
1610    fn lch_is_linear() {
1611        let ntt = LchNtt::<BinaryField16>::default();
1612        let a = matrix::<BinaryField16>(5, 2, 1);
1613        let b = matrix::<BinaryField16>(5, 2, 2);
1614        let sum = RowMajorMatrix::new(
1615            a.values
1616                .iter()
1617                .zip(&b.values)
1618                .map(|(x, y)| *x + *y)
1619                .collect(),
1620            2,
1621        );
1622
1623        let lhs = ntt.ntt_batch(sum);
1624        let rhs_a = ntt.ntt_batch(a);
1625        let rhs_b = ntt.ntt_batch(b);
1626        for (i, v) in lhs.values.iter().enumerate() {
1627            assert_eq!(*v, rhs_a.values[i] + rhs_b.values[i]);
1628        }
1629    }
1630
1631    /// The same data transformed at two levels agrees after embedding: the domain does not
1632    /// depend on the level, so neither does the transform.
1633    #[test]
1634    fn levels_agree_after_embedding() {
1635        const LOG_N: usize = 6;
1636        let coeffs32 = matrix::<BinaryField32>(LOG_N, 1, 7);
1637        let coeffs128 = RowMajorMatrix::new(
1638            coeffs32
1639                .values
1640                .iter()
1641                .map(|v| BinaryField128::from_repr(u128::from(v.to_repr())))
1642                .collect(),
1643            1,
1644        );
1645
1646        let small = LchNtt::<BinaryField32>::default().ntt_batch(coeffs32);
1647        let large = LchNtt::<BinaryField128>::default().ntt_batch(coeffs128);
1648        for (s, l) in small.values.iter().zip(&large.values) {
1649            assert_eq!(u128::from(s.to_repr()), l.to_repr());
1650        }
1651    }
1652
1653    #[test]
1654    fn ghash_packing_matches_scalar_across_tasks() {
1655        // These blocks cross the task-size boundary and leave scalar tails at narrow stages.
1656        for width in [3, 5] {
1657            for shift in [Ghash128::ZERO, sample::<Ghash128>(17)] {
1658                let coeffs = matrix::<Ghash128>(11, width, 29);
1659                let ntt = LchNtt::<Ghash128>::default();
1660                let actual = ntt.shifted_ntt_batch(coeffs.clone(), shift);
1661                // The serial oracle uses scalar products and recomputes every twiddle.
1662                assert_eq!(actual, twiddle_walk_ntt(coeffs.clone(), shift));
1663                assert_eq!(ntt.shifted_intt_batch(actual, shift), coeffs);
1664            }
1665        }
1666    }
1667
1668    /// The transform must commute with the change of basis between the two representations.
1669    /// It is built from twiddle multiplies.
1670    /// Only a field isomorphism preserves multiplication, so this pins that too.
1671    #[test]
1672    fn the_two_representations_of_the_widest_level_transform_alike() {
1673        const LOG_N: usize = 7;
1674        const WIDTH: usize = 3;
1675
1676        // Express one matrix in both field bases.
1677        let tower_coeffs = matrix::<BinaryField128>(LOG_N, WIDTH, 11);
1678        let ghash_coeffs = RowMajorMatrix::new(
1679            tower_coeffs
1680                .values
1681                .iter()
1682                .copied()
1683                .map(Ghash128::from)
1684                .collect(),
1685            WIDTH,
1686        );
1687
1688        let shift = sample::<BinaryField128>(0x0123_4567_89ab_cdef);
1689
1690        let tower = LchNtt::<BinaryField128>::default().shifted_ntt_batch(tower_coeffs, shift);
1691        let ghash =
1692            LchNtt::<Ghash128>::default().shifted_ntt_batch(ghash_coeffs, Ghash128::from(shift));
1693
1694        // Converting before or after the transform must give the same values.
1695        for (t, g) in tower.values.iter().zip(&ghash.values) {
1696            assert_eq!(Ghash128::from(*t), *g);
1697        }
1698    }
1699}