Skip to main content

p3_binary_dft/
poly.rs

1//! The Lin–Chung–Han transform carried out in the polynomial basis of `GF(2^128)`.
2
3use alloc::vec::Vec;
4
5use p3_binary_field::poly_basis::{LOW_STAGES, LowStageTwiddles};
6use p3_binary_field::{BinaryField128, TowerLevel, poly_basis};
7use p3_field::PrimeCharacteristicRing;
8use p3_matrix::Matrix;
9use p3_matrix::dense::RowMajorMatrix;
10use p3_maybe_rayon::prelude::*;
11use p3_util::{log2_ceil_usize, log2_floor_usize, log2_strict_usize};
12
13use crate::domain::domain_point;
14use crate::lch::BUTTERFLY_GRAIN;
15use crate::staging::{
16    Dispatch, StagedRuns, for_each_staged_tile, for_each_staged_tile_into_cosets,
17};
18use crate::traits::AdditiveNtt;
19
20/// [`LchNtt`](crate::LchNtt) over `BinaryField128`, with the data held in the polynomial basis throughout.
21///
22/// A tower-basis product converts both operands into the polynomial basis and the result back,
23/// sixteen dependent table lookups apiece, which is most of what a butterfly costs. Converting
24/// the whole matrix once on the way in and once on the way out pays `2` conversions per element
25/// instead of `3ℓ/2`, and every twiddle multiply in between is a bare carryless multiply and a
26/// reduction. Additions are `XOR` in both bases, so they are unaffected.
27///
28/// Without a carryless-multiply instruction that product is a bit-serial loop and slower than
29/// the tower arithmetic it replaces, so on such a target the transform runs in the tower basis
30/// instead, using typed subfield multiplication when the twiddle permits it.
31/// The choice is a constant and only one arm survives compilation.
32#[derive(Clone, Debug, Default)]
33pub struct PolyBasisNtt {
34    tower: crate::tower::TowerNtt,
35}
36
37/// Stage shifts and the XOR increments between consecutive block twiddles.
38struct Twiddles {
39    basis: [u128; usize::BITS as usize],
40    deltas: [u128; usize::BITS as usize],
41    shifts: [u128; usize::BITS as usize],
42}
43
44impl Twiddles {
45    fn new(log_n: usize, shift: BinaryField128) -> Self {
46        let mut result = Self {
47            basis: [0; usize::BITS as usize],
48            deltas: [0; usize::BITS as usize],
49            shifts: [0; usize::BITS as usize],
50        };
51        let mut delta = 0;
52        let mut base = poly_basis::from_tower(shift);
53        for j in 0..log_n {
54            result.basis[j] = poly_basis::from_tower(BinaryField128::cantor_basis(j + 1));
55            delta ^= result.basis[j];
56            result.deltas[j] = delta;
57            result.shifts[j] = base;
58            base = poly_basis::square(base) ^ base;
59        }
60        result
61    }
62
63    const fn at(&self, stage: usize, mut block: usize) -> u128 {
64        let mut t = self.shifts[stage];
65        while block != 0 {
66            t ^= self.basis[block.trailing_zeros() as usize];
67            block &= block - 1;
68        }
69        t
70    }
71}
72
73/// Schedule enough butterfly-sized work per worker to amortize parallel dispatch.
74// The serial dependency exposes a `const` thread-count query, while the parallel
75// implementation cannot; keep this shared scheduler callable in both configurations.
76#[allow(clippy::missing_const_for_fn)]
77fn use_parallel(elements: usize) -> bool {
78    let threads = p3_maybe_rayon::prelude::current_num_threads();
79    threads > 1 && elements >= 2 * BUTTERFLY_GRAIN * threads
80}
81
82fn for_chunks(
83    values: &mut [u128],
84    chunk_len: usize,
85    stages: usize,
86    operation: impl Fn((usize, &mut [u128])) + Send + Sync,
87) {
88    if values.len() > chunk_len && use_parallel(values.len().saturating_mul(stages.max(1))) {
89        values
90            .par_chunks_mut(chunk_len)
91            .enumerate()
92            .for_each(operation);
93    } else {
94        values.chunks_mut(chunk_len).enumerate().for_each(operation);
95    }
96}
97
98/// Chunk size for copying one coset's coefficients, chosen so each rayon task moves enough
99/// bytes to be worth dispatching, even when only a handful of cosets exist to parallelize over.
100const COSET_COPY_GRAIN: usize = 1 << 16;
101
102/// Copies `src` into `dst`, splitting a large copy across workers.
103///
104/// `for_chunks` gives every coset its own task, but a low-rate encoding has few cosets, so a
105/// large message copy is also split into chunks that idle workers can take.
106fn copy_coset(dst: &mut [u128], src: &[u128]) {
107    if dst.len() > COSET_COPY_GRAIN && use_parallel(dst.len()) {
108        dst.par_chunks_mut(COSET_COPY_GRAIN)
109            .zip(src.par_chunks(COSET_COPY_GRAIN))
110            .for_each(|(d, s)| d.copy_from_slice(s));
111    } else {
112        dst.copy_from_slice(src);
113    }
114}
115
116/// A change of basis applied to a whole run of elements at once.
117///
118/// The kernel behind it converts several elements together where the target allows.
119///
120/// A run therefore has to reach it unbroken, which is why this is not a per-element map.
121type Conversion = fn(&mut [u128]);
122
123/// Rewrites tower-basis bit patterns as polynomial coordinates.
124const INTO_POLY: Conversion = poly_basis::from_tower_slice;
125
126/// Rewrites polynomial coordinates as tower-basis bit patterns.
127const INTO_TOWER: Conversion = poly_basis::to_tower_slice;
128
129/// Change the basis of a whole matrix, in a pass of its own.
130fn convert(values: &mut [u128], conversion: Conversion) {
131    // Basis conversion does several dependent lookups per element, more work than
132    // a butterfly, so it amortizes dispatch at a smaller byte volume.
133    //
134    // A task is a whole grain, so every task still reaches the blocked kernel.
135    if use_parallel(values.len().saturating_mul(4)) {
136        values.par_chunks_mut(BUTTERFLY_GRAIN).for_each(conversion);
137    } else {
138        conversion(values);
139    }
140}
141
142/// Apply a change of basis to a tile the schedule is already holding in cache.
143fn convert_tile(tile: &mut [u128], conversion: Conversion) {
144    conversion(tile);
145}
146
147/// Where the two basis conversions ride, instead of taking a pass over the matrix each.
148///
149/// A conversion is a pure per-element map, so it commutes with every butterfly ordering.
150///
151/// Applied where the schedule already holds the element in cache it costs the lookups alone,
152/// and no memory traffic at all.
153#[derive(Copy, Clone, Debug)]
154struct Fold {
155    /// Map out of the tower basis where the transform first reads each element.
156    entry: bool,
157    /// Map back into the tower basis where the transform last writes each element.
158    exit: bool,
159}
160
161impl Fold {
162    /// Take tower-basis values and hand tower-basis values back.
163    const BOTH: Self = Self {
164        entry: true,
165        exit: true,
166    };
167    /// Take tower-basis values and leave the result in polynomial coordinates.
168    const ENTRY: Self = Self {
169        entry: true,
170        exit: false,
171    };
172    /// Take polynomial coordinates and hand tower-basis values back.
173    const EXIT: Self = Self {
174        entry: false,
175        exit: true,
176    };
177}
178
179/// A stage uses bounded chunks, each starting from its own independently indexed twiddle.
180fn stage(values: &mut [u128], half: usize, j: usize, twiddles: &Twiddles, inverse: bool) {
181    if !use_parallel(values.len()) {
182        local_stage(values, half, j, twiddles, inverse, 0);
183        return;
184    }
185    let blocks_per_chunk = (BUTTERFLY_GRAIN / (half << 1)).max(1);
186    values
187        .par_chunks_mut((half << 1) * blocks_per_chunk)
188        .enumerate()
189        .for_each(|(chunk_index, chunk)| {
190            let first = chunk_index * blocks_per_chunk;
191            let mut t = twiddles.at(j, first);
192            for (index, block) in chunk.chunks_mut(half << 1).enumerate() {
193                let (lo, hi) = block.split_at_mut(half);
194                let butterfly = |lo: &mut [u128], hi: &mut [u128]| {
195                    if inverse {
196                        poly_basis::butterfly_inverse(lo, hi, t);
197                    } else {
198                        poly_basis::butterfly_forward(lo, hi, t);
199                    }
200                };
201                if half <= BUTTERFLY_GRAIN {
202                    butterfly(lo, hi);
203                } else {
204                    lo.par_chunks_mut(BUTTERFLY_GRAIN)
205                        .zip(hi.par_chunks_mut(BUTTERFLY_GRAIN))
206                        .for_each(|(lo, hi)| butterfly(lo, hi));
207                }
208                t ^= twiddles.deltas[(first + index).trailing_ones() as usize];
209            }
210        });
211}
212
213// A conservative tile budget; the row count scales with the element size and matrix width.
214const TILE_BYTES: usize = 32 * 1024;
215
216/// Bytes one worker's staging tile may occupy.
217///
218/// The tile is streamed rather than randomly addressed, so it need not fit in L1.
219/// What it must not do is spill out of the private cache level below the shared one.
220///
221/// A sweep of the fusion depth at width 16 puts the optimum at 64 KiB, which is exactly the
222/// per-core private cache size of the machine the sweep ran on. How many stages that fuses is
223/// the run length divided into it, so it moves with the height rather than being fixed: six
224/// stages at `2^16` rows, seven at `2^20`, eight at `2^22`.
225///
226/// A 1 MiB tile fuses four stages more and takes half as long again.
227const STAGING_BYTES: usize = 64 * 1024;
228
229/// Bytes both tiles may occupy for a matrix the shared cache cannot hold.
230///
231/// Every stage a tile fuses is a traversal of the matrix the transform does not take. Inside
232/// the shared cache those traversals are served from it, and a tile deep enough to remove one
233/// buys a level of cache for a traversal that was cheap. Past it every traversal reaches
234/// memory, and the deeper tile is what keeps the transform off it.
235///
236/// A tile this deep runs a level further out than the budgets above, so it is what the
237/// private cache holds rather than what the level under it holds. The figure is therefore
238/// what one hardware thread has of that cache — its core's share divided by the threads on
239/// the core — halved again. The half is not a second tile, since the two tiles run in
240/// different phases; it is the gather and the scatter, which stream the matrix through the
241/// same cache for as long as the tile is live.
242///
243/// One pass does hold a second tile: the first group every coset of a padded message shares
244/// runs each further coset on a copy of the gathered tile, so it takes twice this budget.
245///
246/// Halving lands on this figure for a core holding 1 MiB of private cache for two threads.
247/// Where a target holds less, the budget is the whole of a thread's share rather than half of
248/// it, and that is the edge the sweep below sits at:
249///
250/// ```text
251///     private cache per core   threads   per thread   this budget is
252///        512 KiB                  2        256 KiB    the whole share
253///          1 MiB                  2        512 KiB    half the share
254///          2 MiB                  2          1 MiB    a quarter of it
255/// ```
256///
257/// A sweep of the budget on the first of those rows, at the one shape past
258/// [`SHARED_CACHE_BYTES`] a transform sweep reaches, as a ratio to the pair above:
259///
260/// ```text
261///     budget      256 KiB   512 KiB   1 MiB
262///     ratio          0.99      1.01    1.02
263/// ```
264///
265/// Arms repeated across that sweep land within 0.2% of each other, so its span is the budget
266/// and not the noise. Past a thread's share the tile costs more than the traversal its own
267/// depth removes, which is what puts the figure at the edge rather than beyond it.
268const DEEP_TILE_BYTES: usize = 256 * 1024;
269
270/// Bytes of shared cache a matrix is taken to be inside.
271///
272/// Which level a traversal is served from is what decides whether [`DEEP_TILE_BYTES`] pays,
273/// and a `no_std` crate cannot read that level's size. The matrix size against a fixed figure
274/// is the signal left.
275///
276/// A sweep of the deep budget against the pair above, over the matrix size, on a core holding
277/// 105 MiB of shared cache for its eight:
278///
279/// ```text
280///     matrix      4 MiB   16 MiB   64 MiB   128 MiB   256 MiB   512 MiB
281///     ratio        1.48     0.95     1.01      1.04      0.98      0.89
282/// ```
283///
284/// The four smallest are width 16 and the two widest a single column, so the curve is one in
285/// the matrix size rather than two in the height.
286///
287/// Only the two ends of it carry: against a noise floor of a few percent the deep budget
288/// loses half again at 4 MiB and wins a tenth at 512 MiB, while the middle is flat inside the
289/// noise and not monotone. So this figure is a bracket between the last matrix that does not
290/// pay for the deeper tile and the first that does, and not a reading of where the shared
291/// cache ends.
292///
293/// The 4 MiB end has a cause of its own that is not the cache level at all: a tile that large
294/// leaves a matrix that small fewer tiles than there are workers to take them.
295///
296/// A target whose shared cache is smaller keeps the shallower tiles on matrices that would
297/// have paid for the deeper one, which is a win forgone rather than a cost added, and that is
298/// the safe direction for a signal this coarse.
299const SHARED_CACHE_BYTES: usize = 128 * 1024 * 1024;
300
301// The tile count the deep budget leaves the smallest matrix that reads it, which is what has
302// to stay far above any worker count a target runs.
303const _: () = assert!(SHARED_CACHE_BYTES / DEEP_TILE_BYTES >= 256);
304
305/// Bytes a gathered run of adjacent rows must cover for the staging to be worth running.
306///
307/// A gather and a scatter address runs a power of two apart, so each address pulls and pushes
308/// whole cache lines whatever the run length. A run shorter than a line therefore moves a
309/// line's worth of traffic to carry a fraction of one:
310///
311/// ```text
312///     16 bytes per run   one quarter of a 64-byte line is useful
313///     64 bytes per run   a whole line is useful
314/// ```
315///
316/// This is the smallest line size the supported targets have, so it is the point past which
317/// no target wastes more than half of a line.
318const STAGED_LINE_BYTES: usize = 64;
319
320/// Bytes a gathered run covers where lengthening it is free.
321///
322/// A line is enough for the gather to use every byte it moves, but a stride that jumps every
323/// line still leaves the prefetcher nothing to follow.
324///
325/// A sweep over runs of 16 to 4096 bytes, at equal element volume on 32 threads:
326///
327/// ```text
328///     run bytes      16     64    256   1024   2048   4096
329///     width 1      7.28   6.32   5.93   5.21   5.76   5.62   ms at 2^22 rows
330///     width 4      5.71   5.74   5.23   4.40   4.88   4.94   ms at 2^20 rows
331///     width 16     4.91   4.73   4.96   4.04   4.72   4.82   ms at 2^18 rows
332/// ```
333///
334/// A run cannot be shorter than one row.
335/// So every cell left of a width's own row length repeats that width's shortest plan.
336///
337/// Width 16's leading three are one plan measured three times.
338/// Their spread of 4.73 to 4.96 puts the noise floor near 5%, which the rest is read against.
339///
340/// The knee is at 1024 bytes, and the rise past it is the depth the tile gives up.
341/// That depth is not free, so this is a target a plan grows towards and not a floor.
342///
343/// A run grows while the shortest run's traversal count still covers it.
344/// It adds a gather the shortest run did not need only once its length has paid for one.
345const STAGED_RUN_BYTES: usize = 1024;
346
347/// Doublings of the run that have to be on offer before a plan buys an added gather.
348///
349/// A leftover stage sweeps the matrix contiguously.
350/// A staged group sweeps it too, and gathers and scatters on top of that.
351///
352/// So trading the leftover for another group holds the traversal count and adds a gather.
353/// The longer run has to cover that gather out of what its own length saves.
354///
355/// What length saves is the stride a prefetcher gets to follow, which grows by doublings.
356/// Forward transforms, as the ratio of the grown plan to the plan that refuses to grow:
357///
358/// ```text
359///     growth    ratios           shapes
360///      2x       1.02 .. 1.13         9
361///      4x       0.96 .. 1.08         9
362///      8x       0.87 .. 0.98        10
363///     16x       0.78 .. 0.85         7
364/// ```
365///
366/// Below 1.00 the longer run wins, swept over 32 workers and over one pinned worker.
367/// A plan measured against itself spans 3% at 32 workers and 1.3% at one.
368///
369/// So one doubling loses and two are a wash, while three already pay on every shape.
370const STAGED_GATHER_DOUBLINGS: usize = 3;
371
372/// Workers a row below [`STAGED_LINE_BYTES`] needs before a tile is worth gathering into.
373///
374/// Staging trades traversals of the matrix for a strided gather and scatter.
375/// One worker issues its traversals far below what memory can serve, so they cost little and
376/// the copy has nothing to pay for it.
377/// Several workers traverse at once against a supply that does not grow, so the traversals
378/// become what the schedule waits on.
379///
380/// Whether a traversal reaches memory at all depends on the last-level cache, which a `no_std`
381/// crate cannot read. The worker count is the available signal, and it is what multiplies
382/// demand against that cache.
383///
384/// Width-1 transforms, as a ratio to the same transform with staging off:
385///
386/// ```text
387///     matrix        4 MiB   8 MiB   16 MiB   32 MiB   64 MiB   128 MiB
388///      1 worker      1.03    1.06     1.13     1.26     1.08      0.99
389///      2 workers     0.97    1.02     1.07     1.16     0.76      0.90
390///      4 workers     0.99    0.90     0.89     0.96     0.81      0.65
391///      8 workers     0.88    0.87     0.79     0.79     0.78      0.49
392///     32 workers     0.49    0.53     0.58     0.65     0.59      0.37
393/// ```
394///
395/// One and two workers share a band from 8 to 32 MiB where the copy costs more than the
396/// traversals it removes, and at two workers that band reaches 16%. Four is the first count
397/// with no such band, and every count above it only wins by more.
398///
399/// # Scope
400///
401/// The count gates only rows too narrow for a run of one row to have reached:
402///
403/// ```text
404///     row >= STAGED_LINE_BYTES   gathered at any worker count
405///     row <  STAGED_LINE_BYTES   gathered from STAGED_WORKERS workers up
406/// ```
407///
408/// Two workers on a 64 MiB matrix give up a quarter here, and a lone worker whose narrow
409/// matrix does outgrow its cache gives up a win of its own. Those are wins forgone rather than
410/// costs added, which is the safe direction for a signal this coarse.
411const STAGED_WORKERS: usize = 4;
412
413/// The shape of one transform, and the two places its stage sequence is cut.
414///
415/// A stage pairs rows a power of two apart, so the whole matrix has to be traversed once per
416/// stage unless a set of rows closed under several stages can be brought into cache.
417///
418/// There are two such sets, and one cut point each:
419///
420/// ```text
421///     stages log_n-1 .. local+leftover   rows far apart, gathered into a staging tile
422///     stages local+leftover-1 .. local   rows far apart, one plain full pass each
423///     stages local-1 .. 0                rows already adjacent, so a contiguous tile holds them
424/// ```
425///
426/// The middle band holds the stages a staging tile would not pay for.
427/// It is one stage wide at most, unless the staging tile cannot hold two runs.
428///
429/// The count of contiguous-tile stages never exceeds the count of stages there are.
430#[derive(Copy, Clone, Debug)]
431struct Plan {
432    /// Elements per row.
433    width: usize,
434    /// Base-two logarithm of the row count.
435    log_n: usize,
436    /// Bottom stages that run to completion inside one contiguous tile of rows.
437    local: usize,
438    /// Base-two logarithm of the adjacent rows one staging gather moves per strided address.
439    ///
440    /// A cache line of run at least, and up to the target wherever its cost is covered.
441    log_block: usize,
442    /// Long-stride stages that one staging tile fuses into a single pass over the matrix.
443    depth: usize,
444}
445
446impl Plan {
447    /// Stages above the contiguous tile that run as plain full passes, for a cut of `above`
448    /// stages into groups of `depth`.
449    ///
450    /// Fusing a single stage would move the same bytes the stage moves on its own, plus the
451    /// copy in and out of the staging tile, so one stage is left over rather than fused.
452    /// A staging tile too narrow to hold two runs leaves every stage over.
453    const fn leftover_stages(above: usize, depth: usize) -> usize {
454        if depth < 2 {
455            above
456        } else if above % depth == 1 {
457            1
458        } else {
459            0
460        }
461    }
462
463    /// The stages each staging group fuses, listed from the top stage downwards.
464    ///
465    /// The walk stops where the leftover stages begin, so a short group lands at the bottom
466    /// of the staged band rather than the top. Both directions and the traversal budget read
467    /// the cut from here, so none of the three can drift from the others.
468    fn groups(above: usize, depth: usize) -> impl Iterator<Item = usize> {
469        let leftover = Self::leftover_stages(above, depth);
470        let mut remaining = above;
471        core::iter::from_fn(move || {
472            (remaining > leftover).then(|| {
473                let take = depth.min(remaining - leftover);
474                remaining -= take;
475                take
476            })
477        })
478    }
479
480    /// Full traversals of the matrix the stages above the contiguous tile take.
481    /// Each fused group is one traversal, and each leftover stage is one of its own.
482    fn traversals(above: usize, depth: usize) -> usize {
483        Self::groups(above, depth).count() + Self::leftover_stages(above, depth)
484    }
485
486    /// The byte budgets a matrix of this shape gives its tiles, contiguous one first.
487    ///
488    /// A matrix no cache holds takes both tiles to [`DEEP_TILE_BYTES`], since the traversals
489    /// the added depth removes are the ones that would have reached memory.
490    ///
491    /// The depth the wider budget buys is paid for twice over, and neither cost is in the
492    /// byte count:
493    ///
494    /// - A tile of a fixed size trades depth against run length one for one, so the deeper
495    ///   tile can leave the search no height at which the run still grows and drop it back to
496    ///   the [`STAGED_LINE_BYTES`] floor. The search prices that trade rather than ignoring
497    ///   it: a traversal saved outranks a doubling of the run.
498    /// - A gather addresses `2^depth` runs a stride apart, and at this budget a stride is far
499    ///   wider than a page, so the pages one tile touches number `2^depth` rather than the
500    ///   tile's own size in pages. That, and not the private cache, is what bounds the depth
501    ///   on a target whose second-level translation buffer holds a few thousand entries.
502    ///
503    /// The comparison is in rows, so no height can overflow the byte count it names.
504    fn budgets(row: usize, log_n: usize) -> (usize, usize) {
505        if log_n > log2_floor_usize((SHARED_CACHE_BYTES / row).max(1)) {
506            (DEEP_TILE_BYTES, DEEP_TILE_BYTES)
507        } else {
508            (TILE_BYTES, STAGING_BYTES)
509        }
510    }
511
512    /// The cut points a matrix of this shape gets, as the schedule that will run it sees them.
513    fn new(width: usize, log_n: usize) -> Self {
514        Self::for_workers(width, log_n, current_num_threads())
515    }
516
517    /// The cut points a matrix of this shape gets, from its memory budgets and its workers.
518    ///
519    /// The staging tile has a fixed byte budget, so doubling the run halves the rows it holds.
520    /// Run length and fused depth therefore trade one for one.
521    ///
522    /// A run below a cache line is never worth gathering, so that length is taken first.
523    /// From there the run grows towards its target against two counts the shortest run sets:
524    ///
525    /// - Traversals of the matrix, which a longer run may never add to.
526    /// - Traversals that gather, which it may add to only once its length has paid for one.
527    ///
528    /// A row under a cache line is gathered from a threshold worker count up.
529    /// Below that count every stage above the contiguous tile runs as a plain pass.
530    fn for_workers(width: usize, log_n: usize, workers: usize) -> Self {
531        let row = core::mem::size_of::<u128>() * width;
532        let (tile, staging) = Self::budgets(row, log_n);
533        let local = log2_floor_usize((tile / row).max(1)).min(log_n);
534        let above = log_n - local;
535        // Stages one tile fuses, with a run of `2^log_block` adjacent rows as its row.
536        let depth_at = |log_block: usize| log2_floor_usize((staging / (row << log_block)).max(1));
537        let floor = log2_ceil_usize(STAGED_LINE_BYTES.div_ceil(row));
538        let target = log2_ceil_usize(STAGED_RUN_BYTES.div_ceil(row)).max(floor);
539        // The shortest admissible run sets both counts no longer run may exceed.
540        let budget = Self::traversals(above, depth_at(floor));
541        let gathers = Self::groups(above, depth_at(floor)).count();
542        let log_block = (floor..=target)
543            .rev()
544            .find(|&log_block| {
545                let depth = depth_at(log_block);
546                // Enough doublings of the run to cover the gather an added group costs.
547                let paid = log_block >= floor + STAGED_GATHER_DOUBLINGS;
548                // The traversal budget admits at most one added group.
549                // So a run that has paid buys exactly the one gather.
550                Self::traversals(above, depth) <= budget
551                    && (paid || Self::groups(above, depth).count() <= gathers)
552            })
553            .unwrap_or(floor);
554        Self {
555            width,
556            log_n,
557            local,
558            log_block,
559            depth: if row >= STAGED_LINE_BYTES || workers >= STAGED_WORKERS {
560                depth_at(log_block)
561            } else {
562                0
563            },
564        }
565    }
566
567    /// Stages above the contiguous tile this plan runs as plain full passes.
568    const fn leftover(&self) -> usize {
569        Self::leftover_stages(self.log_n - self.local, self.depth)
570    }
571
572    /// The stages each of this plan's staging groups fuses, from the top stage downwards.
573    fn group_sizes(&self) -> impl Iterator<Item = usize> {
574        Self::groups(self.log_n - self.local, self.depth)
575    }
576}
577
578/// A tile stays on one worker across its adjacent stages.
579fn local_stage(
580    values: &mut [u128],
581    half: usize,
582    j: usize,
583    twiddles: &Twiddles,
584    inverse: bool,
585    first: usize,
586) {
587    let mut t = twiddles.at(j, first);
588    for (index, block) in values.chunks_mut(half << 1).enumerate() {
589        let (lo, hi) = block.split_at_mut(half);
590        if inverse {
591            poly_basis::butterfly_inverse(lo, hi, t);
592        } else {
593            poly_basis::butterfly_forward(lo, hi, t);
594        }
595        t ^= twiddles.deltas[(first + index).trailing_ones() as usize];
596    }
597}
598
599/// Run the `depth` stages that a tile of `2^depth` consecutive rows of `row` elements holds.
600///
601/// The tile is a radix-2 network on its rows.
602///
603/// Sub-layer `s` pairs rows `2^(depth-1-s)` apart and splits the tile into `2^s` blocks of
604/// `2^(depth-s)` rows, each block carrying one twiddle.
605///
606/// Globally those blocks are the blocks `block * 2^s + g`, `g = 0 .. 2^s`, of stage
607/// `top - 1 - s`, so the twiddle walk starts at `block << s`.
608///
609/// A row of the tile is a row of the matrix for the contiguous phase and a run of adjacent
610/// matrix rows for the staging phase, which is why the row length is a parameter.
611fn tile_stages(
612    tile: &mut [u128],
613    row: usize,
614    depth: usize,
615    top: usize,
616    twiddles: &Twiddles,
617    inverse: bool,
618    block: usize,
619) {
620    for k in 0..depth {
621        // The forward direction runs the widest sub-layer first, the inverse the narrowest.
622        let s = if inverse { depth - 1 - k } else { k };
623        local_stage(
624            tile,
625            (1 << (depth - 1 - s)) * row,
626            top - 1 - s,
627            twiddles,
628            inverse,
629            block << s,
630        );
631    }
632}
633
634/// Complete the stages confined to one cache-sized set of rows before leaving it.
635fn local_stages(values: &mut [u128], plan: Plan, twiddles: &Twiddles, inverse: bool, fold: Fold) {
636    let Plan {
637        width,
638        log_n,
639        local,
640        ..
641    } = plan;
642    let tile_len = (1 << local) * width;
643    // A single column pairs fewer elements than a register holds in its lowest stages, so a
644    // forward tile runs those together over runs of adjacent rows, one register set per run.
645    let low = (!inverse && width == 1 && local >= LOW_STAGES)
646        .then(|| LowStageTwiddles::new(&twiddles.shifts, &twiddles.basis[..log_n]));
647    for_chunks(values, tile_len, local, |(index, tile)| {
648        // The tile is the first read of every element it holds when it runs before every
649        // other stage, and the last write when it runs after them.
650        if fold.entry {
651            convert_tile(tile, INTO_POLY);
652        }
653        if let Some(low) = &low {
654            // The stages above the low ones read a run of adjacent rows as one row, as the
655            // staging phase does.
656            let above = local - LOW_STAGES;
657            tile_stages(tile, 1 << LOW_STAGES, above, local, twiddles, false, index);
658            low.forward(tile, index << above);
659        } else {
660            tile_stages(tile, width, local, local, twiddles, inverse, index);
661        }
662        if fold.exit {
663            convert_tile(tile, INTO_TOWER);
664        }
665    });
666}
667
668/// Run stages `top - 1` down to `top - depth` through one staging tile per worker.
669/// Each of those stages pairs rows far apart, so on its own it reads and writes the whole
670/// matrix.
671///
672/// The rows they touch split into small sets that are closed under all of them, so gathering
673/// one such set into a contiguous tile runs the whole group inside the cache.
674///
675/// The matrix is then read once and written once per group instead of once per stage.
676///
677/// # Algorithm
678///
679/// A gather addresses runs of `L = 2^log_block` adjacent rows, so one strided address moves a
680/// contiguous stretch rather than a fragment of one row.
681/// That is exactly the matrix reread as rows of `L * width` elements:
682///
683/// ```text
684///     width * 2^log_n  =  (L * width) * 2^(log_n - log_block)
685/// ```
686///
687/// Stage `j` pairs rows `2^j` apart, which is `2^(j - log_block)` reshaped rows apart.
688/// So for `j >= log_block` it is stage `j - log_block` of the reshaped matrix.
689///
690/// A run must fit the stride the walk takes, hence `log_block <= top - depth`.
691///
692/// Writing `S = 2^(top - log_block - depth)` with `offset < S`, one closed set is:
693///
694/// ```text
695///     row(k) = block * 2^(top-log_block) + offset + k * S ,     k = 0 .. 2^depth
696/// ```
697///
698/// Stage `top-1-s` pairs those `2^(depth-1-s)` apart in `k`.
699/// That stays inside the set for every `s < depth`, so the set is closed.
700///
701/// # Twiddles
702///
703/// A sub-layer needs the twiddle of the global block its pair lies in, and the two readings
704/// name the same block:
705///
706/// ```text
707///     matrix row    r                lies in block  r >> (top - s)
708///     reshaped row  r >> log_block   lies in block  r >> log_block >> (top - log_block - s)
709/// ```
710///
711/// A shift by `log_block` then by `top - log_block - s` is a shift by `top - s`.
712/// So the `L` matrix rows of one run share one twiddle at every sub-layer, which is what
713/// makes the reshape exact.
714///
715/// Sub-layer `s` then sees block index `block * 2^s + (k >> (depth - s))`.
716/// Writing `k = q * 2^(depth-s) + r` leaves `offset + r * S < 2^(top-log_block-s)` below the
717/// shift, so only `q` survives it.
718///
719/// That is what a contiguous run of `2^(depth-s)` staged rows carries.
720/// So the tile runs as an ordinary radix-2 network whose twiddle walk starts at `block << s`.
721fn fused_stages(
722    values: &mut [u128],
723    plan: Plan,
724    top: usize,
725    depth: usize,
726    twiddles: &Twiddles,
727    inverse: bool,
728    convert_basis: bool,
729) {
730    let (runs, run, dispatch) = staged_group(plan, top, depth, values.len());
731    for_each_staged_tile(values, runs, dispatch, |tile, block| {
732        // The gather is the first read of every element when this is the first group of a
733        // forward transform.
734        if convert_basis && !inverse {
735            convert_tile(tile, INTO_POLY);
736        }
737        tile_stages(tile, run, depth, top, twiddles, inverse, block);
738        // The scatter is the last write of every element when this is the last group of an
739        // inverse transform.
740        if convert_basis && inverse {
741            convert_tile(tile, INTO_TOWER);
742        }
743    });
744}
745
746/// The runs a staging group of `depth` stages below `top` moves, the elements in one run, and
747/// how a pass over `elements` elements spreads its tiles.
748fn staged_group(
749    plan: Plan,
750    top: usize,
751    depth: usize,
752    elements: usize,
753) -> (StagedRuns, usize, Dispatch) {
754    // A group whose stride is shorter than the planned run shortens the run to match.
755    let log_block = plan.log_block.min(top - depth);
756    // One row of the reshaped matrix, in elements.
757    let run = plan.width << log_block;
758    // One staging tile per worker, not per task: a task is a few tens of microseconds of
759    // work and the tile is tens of kilobytes.
760    let dispatch = if use_parallel(elements.saturating_mul(depth)) {
761        Dispatch::Parallel { min_len: 1 }
762    } else {
763        Dispatch::Serial
764    };
765    // Consecutive staged runs are `S = 2^(top - log_block - depth)` reshaped rows apart.
766    let runs = StagedRuns::new(run, top - log_block - depth, depth);
767    (runs, run, dispatch)
768}
769
770/// Run the first staging group of every coset of a zero-padded message in one pass.
771///
772/// `values` is one coset per twiddle set, the leading one holding the message in the tower
773/// basis. Every coset's transform starts from the message's coefficients, and its first group
774/// is its first read of every element. So one gather of the message serves every coset: the
775/// tile changes basis once, and each coset runs the group on its own copy of the tile and
776/// scatters it into its own slot. Neither a pass converting the message nor a copy of it into
777/// each coset is taken.
778fn first_group_into_cosets(
779    values: &mut [u128],
780    message_len: usize,
781    plan: Plan,
782    depth: usize,
783    twiddles: &[Twiddles],
784) {
785    let top = plan.log_n;
786    let (runs, run, dispatch) = staged_group(plan, top, depth, values.len());
787    for_each_staged_tile_into_cosets(
788        values,
789        message_len,
790        runs,
791        dispatch,
792        |tile| convert_tile(tile, INTO_POLY),
793        |tile, block, coset| tile_stages(tile, run, depth, top, &twiddles[coset], false, block),
794    );
795}
796
797/// Encode a zero-padded message whose cosets all share a first staging group of `depth`.
798///
799/// `values` is `2^log_inv_rate` cosets of one message each, the leading one holding the
800/// message in the tower basis, and every coset comes back evaluated in the tower basis.
801fn padded_sharing_first_group(values: &mut [u128], plan: Plan, depth: usize, log_inv_rate: usize) {
802    let log_message = plan.log_n;
803    let len = values.len() >> log_inv_rate;
804    let twiddles: Vec<Twiddles> = (0..1 << log_inv_rate)
805        .map(|c| Twiddles::new(log_message, domain_point(c << log_message)))
806        .collect();
807    first_group_into_cosets(values, len, plan, depth, &twiddles);
808    for_chunks(values, len, log_message, |(c, coset)| {
809        forward_below(coset, plan, &twiddles[c], 1, Fold::EXIT);
810    });
811}
812
813/// Forward transform of polynomial-basis values in an existing allocation.
814fn forward(values: &mut [u128], plan: Plan, shift: BinaryField128, fold: Fold) {
815    forward_below(values, plan, &Twiddles::new(plan.log_n, shift), 0, fold);
816}
817
818/// The forward stages below the first `done` staging groups, which have run already.
819fn forward_below(values: &mut [u128], plan: Plan, twiddles: &Twiddles, done: usize, fold: Fold) {
820    let Plan {
821        width,
822        log_n,
823        local,
824        ..
825    } = plan;
826    // A group that has run already read every element first.
827    debug_assert!(
828        done == 0 || !fold.entry,
829        "the entry conversion rides on the first group"
830    );
831
832    // Peel fused groups from the top stage downwards, each replacing `take` full passes.
833    //
834    // The first group's gather is the first read of every element, so it carries the entry
835    // conversion.
836    let mut entry = fold.entry;
837    let mut top = log_n;
838    for (group, take) in plan.group_sizes().enumerate() {
839        if group >= done {
840            fused_stages(values, plan, top, take, twiddles, false, entry);
841            entry = false;
842        }
843        top -= take;
844    }
845
846    // A plain pass carries no per-element map, so a conversion still owed ahead of one takes
847    // a pass of its own.
848    if entry && top > local {
849        convert(values, INTO_POLY);
850        entry = false;
851    }
852    for j in (local..top).rev() {
853        stage(values, (1 << j) * width, j, twiddles, false);
854    }
855
856    // The contiguous tile finishes the bottom stages, and is the last write of every
857    // element.
858    local_stages(
859        values,
860        plan,
861        twiddles,
862        false,
863        Fold {
864            entry,
865            exit: fold.exit,
866        },
867    );
868}
869
870/// Inverse transform with the data kept in the polynomial basis.
871fn inverse(values: &mut [u128], plan: Plan, shift: BinaryField128, fold: Fold) {
872    let Plan {
873        width,
874        log_n,
875        local,
876        ..
877    } = plan;
878    let twiddles = Twiddles::new(log_n, shift);
879    let leftover = plan.leftover();
880
881    // The contiguous tile runs first, so it is the first read of every element, and the last
882    // write too when no stage runs above it.
883    let tile_exit = fold.exit && local == log_n;
884    let mut exit = fold.exit && !tile_exit;
885    local_stages(
886        values,
887        plan,
888        &twiddles,
889        true,
890        Fold {
891            entry: fold.entry,
892            exit: tile_exit,
893        },
894    );
895
896    // Stages the staging tile does not pay for go before the groups, so a group still
897    // reaches the top stage and can carry the exit conversion.
898    for j in local..local + leftover {
899        stage(values, (1 << j) * width, j, &twiddles, true);
900    }
901
902    // The groups come off the top downwards, so running them bottom up walks the same list
903    // backwards. Its length is the group count, which never exceeds the stage count.
904    let mut sizes = [0; usize::BITS as usize];
905    let mut count = 0;
906    for take in plan.group_sizes() {
907        sizes[count] = take;
908        count += 1;
909    }
910    let mut base = local + leftover;
911    for (index, take) in sizes[..count].iter().rev().enumerate() {
912        base += take;
913        let last = index + 1 == count;
914        fused_stages(values, plan, base, *take, &twiddles, true, exit && last);
915        exit &= !last;
916    }
917
918    // No group ran, so the exit conversion needs a pass of its own.
919    if exit {
920        convert(values, INTO_TOWER);
921    }
922}
923
924impl AdditiveNtt<BinaryField128> for PolyBasisNtt {
925    fn shifted_ntt_batch(
926        &self,
927        mut mat: RowMajorMatrix<BinaryField128>,
928        shift: BinaryField128,
929    ) -> RowMajorMatrix<BinaryField128> {
930        if !poly_basis::HAS_HARDWARE_CLMUL {
931            return self.tower.shifted_ntt_batch(mat, shift);
932        }
933
934        let width = mat.width();
935        let log_n = log2_strict_usize(mat.height());
936        // `BinaryField128` is `#[repr(transparent)]` over `u128`, so this reuses `mat.values`'s
937        // allocation instead of allocating a second codeword buffer: `to_repr` is the identity
938        // bit pattern, and the specialised `Vec` collect below reinterprets the buffer in place.
939        let mut values: Vec<u128> = core::mem::take(&mut mat.values)
940            .into_iter()
941            .map(BinaryField128::to_repr)
942            .collect();
943
944        // Both conversions ride along with the transform's first and last touch of each
945        // element, so neither costs a pass over the matrix.
946        forward(&mut values, Plan::new(width, log_n), shift, Fold::BOTH);
947
948        mat.values = values.into_iter().map(BinaryField128::from_repr).collect();
949        mat
950    }
951
952    fn ntt_batch_padded(
953        &self,
954        mut mat: RowMajorMatrix<BinaryField128>,
955        log_inv_rate: usize,
956    ) -> RowMajorMatrix<BinaryField128> {
957        let log_n = log2_strict_usize(mat.height());
958        assert!(log_inv_rate <= log_n, "padding exceeds matrix height");
959        if log_inv_rate == 0 || !poly_basis::HAS_HARDWARE_CLMUL {
960            return self.ntt_batch(mat);
961        }
962        let width = mat.width;
963        let log_message = log_n - log_inv_rate;
964        let len = mat.values.len() >> log_inv_rate;
965        let mut values: Vec<u128> = core::mem::take(&mut mat.values)
966            .into_iter()
967            .map(BinaryField128::to_repr)
968            .collect();
969        let plan = Plan::new(width, log_message);
970        let large = len >= 2 * BUTTERFLY_GRAIN * p3_maybe_rayon::prelude::current_num_threads();
971        if let Some(depth) = plan.group_sizes().next().filter(|_| large) {
972            // Large cosets that stage their first group read the message once for all of them,
973            // and every coset finishes on its own.
974            padded_sharing_first_group(&mut values, plan, depth, log_inv_rate);
975            mat.values = values.into_iter().map(BinaryField128::from_repr).collect();
976            return mat;
977        }
978        let (message, tail) = values.split_at_mut(len);
979        // Every coset starts from a copy of the message, so one conversion of the message
980        // alone serves all `2^log_inv_rate` of them.
981        //
982        // Converting each copy instead would repeat the sixteen dependent lookups per element
983        // once per coset, for nothing.
984        convert(message, INTO_POLY);
985        // Only the conversion back is left, and each coset's contiguous tile carries its own.
986        if large {
987            // Copy each large coset next to its evaluation, including when only one worker
988            // is available.
989            for_chunks(tail, len, log_message, |(c, chunk)| {
990                copy_coset(chunk, message);
991                forward(
992                    chunk,
993                    plan,
994                    domain_point((c + 1) << log_message),
995                    Fold::EXIT,
996                );
997            });
998            forward(message, plan, BinaryField128::ZERO, Fold::EXIT);
999        } else {
1000            // Small cosets can run together after all coefficient copies are made.
1001            for_chunks(tail, len, 1, |(_, chunk)| chunk.copy_from_slice(message));
1002            for_chunks(&mut values, len, log_message, |(c, chunk)| {
1003                forward(chunk, plan, domain_point(c << log_message), Fold::EXIT);
1004            });
1005        }
1006        mat.values = values.into_iter().map(BinaryField128::from_repr).collect();
1007        mat
1008    }
1009
1010    fn shifted_lde_batch(
1011        &self,
1012        mut mat: RowMajorMatrix<BinaryField128>,
1013        added_bits: usize,
1014        shift: BinaryField128,
1015    ) -> RowMajorMatrix<BinaryField128> {
1016        if !poly_basis::HAS_HARDWARE_CLMUL {
1017            return self.tower.shifted_lde_batch(mat, added_bits, shift);
1018        }
1019        let log_n = log2_strict_usize(mat.height());
1020        if added_bits == 0 {
1021            return mat;
1022        }
1023        let width = mat.width;
1024        let len = mat.values.len();
1025        let padded_len = u32::try_from(added_bits)
1026            .ok()
1027            .and_then(|bits| len.checked_shl(bits))
1028            .filter(|&padded| padded >> added_bits == len)
1029            .expect("extended codeword length overflows usize");
1030        // Preserve the evaluation prefix in its final allocation, then reuse the
1031        // original allocation for coefficients instead of cloning it before a resize.
1032        let mut coeffs: Vec<u128> = core::mem::take(&mut mat.values)
1033            .into_iter()
1034            .map(BinaryField128::to_repr)
1035            .collect();
1036        let mut values = alloc::vec![0u128; padded_len];
1037        values[..len].copy_from_slice(&coeffs);
1038        // The coefficients stay in the polynomial basis: every coset transform starts from a
1039        // copy of them.
1040        let plan = Plan::new(width, log_n);
1041        inverse(&mut coeffs, plan, shift, Fold::ENTRY);
1042
1043        // The input evaluations already are the first coset. Only new cosets need
1044        // evaluation and conversion back from the polynomial basis.
1045        for_chunks(&mut values[len..], len, log_n, |(c, chunk)| {
1046            chunk.copy_from_slice(&coeffs);
1047            forward(
1048                chunk,
1049                plan,
1050                shift + domain_point::<BinaryField128>((c + 1) << log_n),
1051                Fold::EXIT,
1052            );
1053        });
1054        mat.values = values.into_iter().map(BinaryField128::from_repr).collect();
1055        mat
1056    }
1057
1058    fn shifted_intt_batch(
1059        &self,
1060        mut mat: RowMajorMatrix<BinaryField128>,
1061        shift: BinaryField128,
1062    ) -> RowMajorMatrix<BinaryField128> {
1063        if !poly_basis::HAS_HARDWARE_CLMUL {
1064            return self.tower.shifted_intt_batch(mat, shift);
1065        }
1066
1067        let width = mat.width();
1068        let log_n = log2_strict_usize(mat.height());
1069        // See `shifted_ntt_batch`: reuses `mat.values`'s allocation instead of a second buffer.
1070        let mut values: Vec<u128> = core::mem::take(&mut mat.values)
1071            .into_iter()
1072            .map(BinaryField128::to_repr)
1073            .collect();
1074
1075        // See the forward transform: neither conversion costs a pass of its own.
1076        inverse(&mut values, Plan::new(width, log_n), shift, Fold::BOTH);
1077
1078        mat.values = values.into_iter().map(BinaryField128::from_repr).collect();
1079        mat
1080    }
1081}
1082
1083#[cfg(test)]
1084mod tests {
1085    use alloc::vec::Vec;
1086
1087    use p3_binary_field::{BinaryField128, TowerLevel, poly_basis};
1088    use p3_field::PrimeCharacteristicRing;
1089    use p3_matrix::dense::RowMajorMatrix;
1090    use p3_util::log2_floor_usize;
1091    use proptest::prelude::*;
1092
1093    use super::{Fold, Plan, PolyBasisNtt, STAGED_RUN_BYTES, STAGED_WORKERS};
1094    use crate::domain::{domain_point, subspace_polynomial};
1095    use crate::lch::LchNtt;
1096    use crate::naive::NaiveAdditiveNtt;
1097    use crate::traits::AdditiveNtt;
1098
1099    /// Cut points small enough to keep the test matrices tiny.
1100    ///
1101    /// One `(local, log_block, depth)` triple per branch of the schedule:
1102    ///
1103    /// ```text
1104    ///     (2, 0, 3)  full groups, plus a one-stage leftover at local + depth + 1
1105    ///     (2, 2, 3)  the same cut with runs of four rows per strided address
1106    ///     (2, 0, 0)  a staging tile too narrow for two runs, so no group runs at all
1107    ///     (0, 0, 3)  no contiguous tile, so every stage is fused
1108    ///     (0, 3, 3)  runs as long as a whole group's stride, which the group has to shorten
1109    ///     (1, 1, 1)  a depth of one, which never pays for a tile
1110    /// ```
1111    const CUTS: [(usize, usize, usize); 6] = [
1112        (2, 0, 3),
1113        (2, 2, 3),
1114        (2, 0, 0),
1115        (0, 0, 3),
1116        (0, 3, 3),
1117        (1, 1, 1),
1118    ];
1119
1120    /// Cuts and heights whose staging groups number three or more, with the group depths
1121    /// each one produces:
1122    ///
1123    /// ```text
1124    ///     (0, 0, 3, 8)   3 + 3 + 2       a short last group, no contiguous tile
1125    ///     (0, 1, 3, 9)   3 + 3 + 3       three groups of full depth, runs of two rows
1126    ///     (1, 2, 2, 8)   2 + 2 + 2       three groups and a one-stage leftover
1127    ///     (0, 0, 2, 8)   2 + 2 + 2 + 2   four groups of full depth
1128    /// ```
1129    ///
1130    /// The staging loop reseeds its twiddle walk and its conversion flag on every turn, so
1131    /// only a third turn shows that the reseeding is not accidentally right for two.
1132    const DEEP_CUTS: [(usize, usize, usize, usize); 4] =
1133        [(0, 0, 3, 8), (0, 1, 3, 9), (1, 2, 2, 8), (0, 0, 2, 8)];
1134
1135    /// Widths that cover a single element per row, an odd row, and rows of several elements.
1136    const WIDTHS: [usize; 5] = [1, 2, 3, 16, 64];
1137
1138    /// Production shapes and the cut each one gets, as `width, log_n, (local, log_block,
1139    /// depth)`, at a worker count past [`STAGED_WORKERS`].
1140    ///
1141    /// The heights put the branch that is interesting for that width on it:
1142    ///
1143    /// ```text
1144    ///     width    1 @ 2^10   the tile is the whole transform
1145    ///     width    1 @ 2^14   one group of six stages, runs of 64 rows
1146    ///     width    1 @ 2^20   the production commit shape, one group of nine
1147    ///     width    2 @ 2^13   one group of six stages, runs of 32 rows
1148    ///     width    3 @ 2^12   an odd width, whose 32-row run overshoots
1149    ///     width    4 @ 2^14   the widest row a run still spans several of
1150    ///     width   16 @ 2^10   a short group, above a tile of 128 rows
1151    ///     width   16 @ 2^14   one full group, at a height that moves the cut
1152    ///     width   64 @ 2^10   one group of six stages, a run of one row
1153    ///     width  512 @ 2^8    two groups of three stages
1154    ///     width 1024 @ 2^7    three groups of two stages
1155    /// ```
1156    const PRODUCTION_CUTS: [(usize, usize, (usize, usize, usize)); 11] = [
1157        (1, 10, (10, 6, 6)),
1158        (1, 14, (11, 6, 6)),
1159        (1, 20, (11, 3, 9)),
1160        (2, 13, (10, 5, 6)),
1161        (3, 12, (9, 5, 5)),
1162        (4, 14, (9, 4, 6)),
1163        (16, 10, (7, 2, 6)),
1164        (16, 14, (7, 1, 7)),
1165        (64, 10, (5, 0, 6)),
1166        (512, 8, (2, 0, 3)),
1167        (1024, 7, (1, 0, 2)),
1168    ];
1169
1170    /// Shapes whose cut is pinned as a triple alone, since transforming them costs too much.
1171    ///
1172    /// Each row is a run-length decision, and what decides it:
1173    ///
1174    /// ```text
1175    ///     width 48 @ 2^18   one doubling on offer, the least a growing run can have
1176    ///     width 16 @ 2^16   two doublings, so the added gather stays unpaid
1177    ///     width  8 @ 2^18   three doublings, which is exactly what a gather costs
1178    ///     width  4 @ 2^20   four doublings, the widest row a run still spans several of
1179    ///     width  1 @ 2^22   four doublings again, from a run floor above one row
1180    ///     width 16 @ 2^18   no leftover pass, so growth rebalances (8, 3) into (6, 5)
1181    ///     width 16 @ 2^20   a deep tile whose one group takes every stage above it
1182    ///     width  1 @ 2^25   a deep tile whose run gives doublings up to stay one group
1183    /// ```
1184    ///
1185    /// The first and the last two are past [`SHARED_CACHE_BYTES`], so they are the rows that
1186    /// read the deep budget. Width 16 at `2^20` rows is `2^24` elements, which is why none of
1187    /// these is transformed.
1188    const PLAN_ONLY_CUTS: [(usize, usize, (usize, usize, usize)); 8] = [
1189        (48, 18, (8, 1, 7)),
1190        (16, 16, (7, 0, 8)),
1191        (8, 18, (8, 3, 6)),
1192        (4, 20, (9, 4, 6)),
1193        (1, 22, (11, 6, 6)),
1194        (16, 18, (7, 2, 6)),
1195        (16, 20, (10, 0, 10)),
1196        (1, 25, (14, 3, 11)),
1197    ];
1198
1199    /// Staging groups the deep budget produces, as `width, log_block, depth`.
1200    ///
1201    /// These are the group depths and run lengths of the deep rows of [`PLAN_ONLY_CUTS`],
1202    /// which are magnitudes no cut small enough to transform ever reaches:
1203    ///
1204    /// ```text
1205    ///     width 48   seven stages per group, a run of two rows
1206    ///     width 16   ten stages per group, a run of one row
1207    ///     width  1   eleven stages per group, a run of eight rows
1208    /// ```
1209    const DEEP_BUDGET_GROUPS: [(usize, usize, usize); 3] = [(48, 1, 7), (16, 0, 10), (1, 3, 11)];
1210
1211    /// Contiguous tiles the deep budget produces, as `width, local`.
1212    ///
1213    /// A tile of [`DEEP_TILE_BYTES`] is `2^local` rows of the width beside it:
1214    ///
1215    /// ```text
1216    ///     width  1   a tile of 2^14 rows
1217    ///     width 16   a tile of 2^10 rows
1218    ///     width 48   a tile of 2^8 rows
1219    /// ```
1220    const DEEP_BUDGET_TILES: [(usize, usize); 3] = [(1, 14), (16, 10), (48, 8)];
1221
1222    /// Cuts whose staging groups run at a height the reference oracle can still reach:
1223    ///
1224    /// ```text
1225    ///     (2, 0, 3, 6)   one group of three stages, plus a one-stage leftover
1226    ///     (2, 2, 3, 6)   the same cut with runs of four rows per strided address
1227    ///     (1, 1, 2, 5)   two groups of two stages, runs of two rows
1228    ///     (0, 0, 3, 6)   no contiguous tile, so every stage is fused
1229    /// ```
1230    const ORACLE_CUTS: [(usize, usize, usize, usize); 4] =
1231        [(2, 0, 3, 6), (2, 2, 3, 6), (1, 1, 2, 5), (0, 0, 3, 6)];
1232
1233    /// Widths from one element per row up to a row that spans a gathered run on its own.
1234    ///
1235    /// A run covers several rows at every width below `64`, and one row from there up.
1236    /// So this covers both regimes and the boundary between them.
1237    const ORACLE_WIDTHS: [usize; 10] = [1, 2, 3, 4, 5, 6, 7, 8, 16, 64];
1238
1239    /// The tallest matrix the width sweeps transform.
1240    ///
1241    /// The production plan of a width-1 matrix fuses its first group at `2^13`.
1242    /// So a sweep has to reach past that before it exercises a staged gather at all.
1243    const MAX_LOG_N: usize = 14;
1244
1245    /// The tallest matrix one width is swept to.
1246    ///
1247    /// A row of [`STAGED_RUN_BYTES`] or more gathers one row per strided address at every
1248    /// height, so once its first group has appeared the sweep only repeats a plan the cut
1249    /// tests already run — at over half the cost of the whole sweep, since these are the
1250    /// widest matrices in it.
1251    fn max_log_n(width: usize) -> usize {
1252        if core::mem::size_of::<u128>() * width >= STAGED_RUN_BYTES {
1253            10
1254        } else {
1255            MAX_LOG_N
1256        }
1257    }
1258
1259    /// The tallest matrix the reference transform is asked for.
1260    ///
1261    /// The oracle costs `O(n^2 log n)` per column, so `2^7` rows is already seconds of an
1262    /// unoptimised test run. No production plan gathers a tile that low, which is why the
1263    /// staged path reaches the oracle through the synthetic cuts of [`ORACLE_CUTS`] rather
1264    /// than by making this sweep taller.
1265    const REFERENCE_LOG_N: usize = 6;
1266
1267    /// Neither basis conversion rides along, so only the stage schedule is under test.
1268    const NONE: Fold = Fold {
1269        entry: false,
1270        exit: false,
1271    };
1272
1273    /// A shift with bits in both halves, so no twiddle is accidentally zero.
1274    fn test_shift() -> BinaryField128 {
1275        BinaryField128::from_repr((1 << 127) | 7919)
1276    }
1277
1278    /// Values whose bits depend on the position, so a misapplied twiddle cannot cancel out.
1279    fn coefficients(log_n: usize, width: usize) -> Vec<u128> {
1280        (0..(width << log_n))
1281            .map(|i| {
1282                let low = (i as u64)
1283                    .wrapping_mul(0x9e37_79b9_7f4a_7c15)
1284                    .wrapping_add(0x5555_5555_5555_5555);
1285                let high = low.rotate_left(17).wrapping_mul(0xc2b2_ae3d_27d4_eb4f);
1286                (u128::from(high) << 64) | u128::from(low)
1287            })
1288            .collect()
1289    }
1290
1291    /// One full pass per stage, straight off the twiddle accessor: the schedule every cut of
1292    /// the stage sequence has to reproduce bit for bit.
1293    fn per_stage_schedule(
1294        values: &mut [u128],
1295        width: usize,
1296        log_n: usize,
1297        shift: BinaryField128,
1298        inverse: bool,
1299    ) {
1300        let twiddles = super::Twiddles::new(log_n, shift);
1301        for k in 0..log_n {
1302            // The forward direction runs the widest stage first, the inverse the narrowest.
1303            let j = if inverse { k } else { log_n - 1 - k };
1304            // Stage `j` pairs rows `2^j` apart, so a block spans `2^(j+1)` rows and the
1305            // block index alone picks the twiddle.
1306            let half = (1 << j) * width;
1307            for (block, rows) in values.chunks_mut(half << 1).enumerate() {
1308                let t = twiddles.at(j, block);
1309                let (lo, hi) = rows.split_at_mut(half);
1310                if inverse {
1311                    poly_basis::butterfly_inverse(lo, hi, t);
1312                } else {
1313                    poly_basis::butterfly_forward(lo, hi, t);
1314                }
1315            }
1316        }
1317    }
1318
1319    /// Run the scheduled transform of one direction in place.
1320    fn scheduled(values: &mut [u128], plan: Plan, inverse: bool, fold: Fold) {
1321        if inverse {
1322            super::inverse(values, plan, test_shift(), fold);
1323        } else {
1324            super::forward(values, plan, test_shift(), fold);
1325        }
1326    }
1327
1328    /// Builds a matrix whose entries are distinct functions of the seed and the position.
1329    fn matrix(log_n: usize, width: usize, seed: u64) -> RowMajorMatrix<BinaryField128> {
1330        RowMajorMatrix::new(
1331            (0..(width << log_n))
1332                .map(|i| {
1333                    let bits = seed
1334                        .wrapping_mul(0x9e37_79b9_7f4a_7c15)
1335                        .wrapping_add(i as u64);
1336                    BinaryField128::from_le_byte_iter(bits.to_le_bytes().into_iter().cycle())
1337                })
1338                .collect(),
1339            width,
1340        )
1341    }
1342
1343    /// One plan per branch of the cut, at every width.
1344    ///
1345    /// The synthetic heights are the branch boundaries of the cut:
1346    ///
1347    /// ```text
1348    ///     local            nothing above the tile, so the tile is the whole transform
1349    ///     local + 1        one stage above the tile, which is left unfused
1350    ///     local + depth    one full staging group
1351    ///     local + depth+1  a full group and a one-stage leftover
1352    ///     2*local + depth  several groups and several tiles
1353    /// ```
1354    ///
1355    /// The deep cuts then carry the heights that need three or more groups.
1356    fn cut_plans() -> Vec<Plan> {
1357        let mut plans = Vec::new();
1358        for (local, log_block, depth) in CUTS {
1359            for log_n in [
1360                local,
1361                local + 1,
1362                local + depth,
1363                local + depth + 1,
1364                2 * local + depth,
1365            ] {
1366                for width in WIDTHS {
1367                    plans.push(Plan {
1368                        width,
1369                        log_n,
1370                        local: local.min(log_n),
1371                        log_block,
1372                        depth,
1373                    });
1374                }
1375            }
1376        }
1377        for (local, log_block, depth, log_n) in DEEP_CUTS {
1378            for width in WIDTHS {
1379                plans.push(Plan {
1380                    width,
1381                    log_n,
1382                    local,
1383                    log_block,
1384                    depth,
1385                });
1386            }
1387        }
1388        plans
1389    }
1390
1391    #[test]
1392    fn every_cut_of_the_stage_sequence_matches_the_per_stage_schedule() {
1393        // Invariant: cutting the stage sequence into staging groups and a contiguous tile is
1394        // a pure reordering of memory traffic, so every element must come out bit for bit
1395        // what one full pass per stage produces, in both directions.
1396        for plan in cut_plans() {
1397            let Plan { width, log_n, .. } = plan;
1398            for inverse in [false, true] {
1399                let mut expected = coefficients(log_n, width);
1400                let mut actual = expected.clone();
1401                per_stage_schedule(&mut expected, width, log_n, test_shift(), inverse);
1402                scheduled(&mut actual, plan, inverse, NONE);
1403                assert_eq!(actual, expected, "{plan:?} inverse={inverse}");
1404            }
1405        }
1406
1407        // The production cut points, at a worker count past the threshold, so the same cut
1408        // runs here on a serial build and a parallel one. What each cut is, rather than what
1409        // it does, is pinned where the budgets are tested.
1410        for (width, log_n, _) in PRODUCTION_CUTS {
1411            let plan = Plan::for_workers(width, log_n, 32);
1412            for inverse in [false, true] {
1413                let mut expected = coefficients(log_n, width);
1414                let mut actual = expected.clone();
1415                per_stage_schedule(&mut expected, width, log_n, test_shift(), inverse);
1416                scheduled(&mut actual, plan, inverse, NONE);
1417                assert_eq!(actual, expected, "{plan:?} inverse={inverse}");
1418            }
1419        }
1420    }
1421
1422    #[test]
1423    fn a_deep_budget_cut_matches_the_per_stage_schedule() {
1424        // Invariant: the depths and run lengths the deep budget produces reorder the memory
1425        // traffic like any other cut, so they too have to come out bit for bit what one full
1426        // pass per stage produces. A matrix whose shape reads that budget is far past the
1427        // height a test can transform, so the two regimes are carried onto short matrices
1428        // instead: the group depths over a contiguous tile small enough to leave them room,
1429        // and the tile depths under a shallow group.
1430        let matches_per_stage = |plan: Plan| {
1431            let Plan { width, log_n, .. } = plan;
1432            for inverse in [false, true] {
1433                let mut expected = coefficients(log_n, width);
1434                let mut actual = expected.clone();
1435                per_stage_schedule(&mut expected, width, log_n, test_shift(), inverse);
1436                scheduled(&mut actual, plan, inverse, NONE);
1437                assert_eq!(actual, expected, "{plan:?} inverse={inverse}");
1438            }
1439        };
1440
1441        // A group whose stride is shorter than the run shortens the run, so the tile sits one
1442        // stage above the run length to leave the planned run standing.
1443        for (width, log_block, depth) in DEEP_BUDGET_GROUPS {
1444            let local = log_block + 1;
1445            // One full group, a group and a leftover pass, then a short group under a full one.
1446            for extra in [0usize, 1, 2] {
1447                matches_per_stage(Plan {
1448                    width,
1449                    log_n: local + depth + extra,
1450                    local,
1451                    log_block,
1452                    depth,
1453                });
1454            }
1455        }
1456
1457        // A tile this deep carries most of the transform, so the group above it is shallow.
1458        for (width, local) in DEEP_BUDGET_TILES {
1459            for extra in [1usize, 2, 3] {
1460                matches_per_stage(Plan {
1461                    width,
1462                    log_n: local + extra,
1463                    local,
1464                    log_block: 0,
1465                    depth: 2,
1466                });
1467            }
1468        }
1469    }
1470
1471    #[test]
1472    fn every_width_and_height_matches_the_per_stage_schedule() {
1473        // Invariant: the rows a gather moves per strided address regroup the matrix, they
1474        // do not change the transform. The run length a plan picks varies over the narrow
1475        // widths and the cut points move with the height, so the two are swept together
1476        // against the per-stage schedule.
1477        for width in ORACLE_WIDTHS {
1478            for log_n in 0..=max_log_n(width) {
1479                // One worker gathers no tile where many do, so both readings of one shape
1480                // are swept, not just this build's own.
1481                for workers in [1, 32] {
1482                    let plan = Plan::for_workers(width, log_n, workers);
1483                    for shift in [BinaryField128::ZERO, test_shift()] {
1484                        for inverse in [false, true] {
1485                            let mut expected = coefficients(log_n, width);
1486                            let mut actual = expected.clone();
1487                            per_stage_schedule(&mut expected, width, log_n, shift, inverse);
1488                            if inverse {
1489                                super::inverse(&mut actual, plan, shift, NONE);
1490                            } else {
1491                                super::forward(&mut actual, plan, shift, NONE);
1492                            }
1493                            assert_eq!(
1494                                actual, expected,
1495                                "{plan:?} shift={shift:?} inverse={inverse}"
1496                            );
1497                        }
1498                    }
1499                }
1500            }
1501        }
1502    }
1503
1504    /// Full passes over the matrix a cut of `above` stages into groups of `depth` takes,
1505    /// counted by walking the loop [`super::forward`] walks rather than by asking the plan.
1506    ///
1507    /// Each turn of the staging loop reads and writes the matrix once, and each stage the
1508    /// loop stops short of is a plain pass of its own. Nothing here calls into `Plan`, so a
1509    /// plan that quietly buys its run length with an extra pass comes out with a larger
1510    /// count than the run it is measured against.
1511    fn passes_above_the_tile(above: usize, depth: usize) -> usize {
1512        let leftover = if depth < 2 {
1513            above
1514        } else {
1515            usize::from(above % depth == 1)
1516        };
1517        let mut passes = 0;
1518        let mut remaining = above;
1519        while remaining > leftover {
1520            remaining -= depth.min(remaining - leftover);
1521            passes += 1;
1522        }
1523        passes + remaining
1524    }
1525
1526    #[test]
1527    fn the_plan_gathers_the_longest_run_its_budgets_allow() {
1528        // The production cut points, as `local, log_block, depth`, past the worker threshold.
1529        //
1530        // A run length is a choice between two plans that take the same number of passes.
1531        // So no pass count can pin it, and these triples are what says which one is picked.
1532        for (width, log_n, cut) in PRODUCTION_CUTS.into_iter().chain(PLAN_ONLY_CUTS) {
1533            let plan = Plan::for_workers(width, log_n, 32);
1534            assert_eq!((plan.local, plan.log_block, plan.depth), cut, "{plan:?}");
1535        }
1536
1537        // Invariant: growing the run never costs a pass. Gathering one row per strided
1538        // address is the shortest run there is, so its pass count is the ceiling, and both
1539        // sides are counted by walking the staging loop rather than by the rule that picked
1540        // the run.
1541        for width in [4usize, 5, 8, 16, 64, 512, 1024] {
1542            for log_n in 0..=24 {
1543                let plan = Plan::for_workers(width, log_n, 32);
1544                let row = core::mem::size_of::<u128>() * width;
1545                let one_row = log2_floor_usize((Plan::budgets(row, log_n).1 / row).max(1));
1546                let above = log_n - plan.local;
1547                assert!(
1548                    passes_above_the_tile(above, plan.depth)
1549                        <= passes_above_the_tile(above, one_row),
1550                    "{plan:?}"
1551                );
1552            }
1553        }
1554    }
1555
1556    #[test]
1557    fn a_staged_cut_matches_the_reference_transform() {
1558        // The oracle depends on none of the identities the fast transform is built from, but
1559        // it costs `O(n^2 log n)` per column, so a production plan's first staging group sits
1560        // far above the heights it can reach. These cuts put a gather and a scatter inside
1561        // its range instead, which is what pins the staged path to the map rather than to the
1562        // stage schedule alone.
1563        let naive = NaiveAdditiveNtt::<BinaryField128>::default();
1564        for (local, log_block, depth, log_n) in ORACLE_CUTS {
1565            for width in [1usize, 3, 16] {
1566                let plan = Plan {
1567                    width,
1568                    log_n,
1569                    local,
1570                    log_block,
1571                    depth,
1572                };
1573                assert!(
1574                    plan.group_sizes().next().is_some(),
1575                    "{plan:?} gathers no tile"
1576                );
1577                for shift in [BinaryField128::ZERO, test_shift()] {
1578                    let coeffs = matrix(log_n, width, 3);
1579                    let evals = naive.shifted_ntt_batch(coeffs.clone(), shift);
1580                    let reprs = |mat: &RowMajorMatrix<BinaryField128>| -> Vec<u128> {
1581                        mat.values
1582                            .iter()
1583                            .copied()
1584                            .map(BinaryField128::to_repr)
1585                            .collect()
1586                    };
1587
1588                    // Both conversions ride along, so this is what `shifted_ntt_batch` runs.
1589                    let mut actual = reprs(&coeffs);
1590                    super::forward(&mut actual, plan, shift, Fold::BOTH);
1591                    assert_eq!(actual, reprs(&evals), "ntt {plan:?} shift={shift:?}");
1592
1593                    let mut actual = reprs(&evals);
1594                    super::inverse(&mut actual, plan, shift, Fold::BOTH);
1595                    assert_eq!(actual, reprs(&coeffs), "intt {plan:?} shift={shift:?}");
1596                }
1597            }
1598        }
1599    }
1600
1601    #[test]
1602    fn a_row_that_covers_a_line_is_gathered_whatever_the_worker_count() {
1603        // Invariant: the worker count may only switch off a staging that one row per
1604        // address never reached, so a row of a line or more keeps its tile at every count.
1605        for width in [4usize, 16, 64, 512] {
1606            for log_n in [10, 14, 20] {
1607                let alone = Plan::for_workers(width, log_n, 1);
1608                let shared = Plan::for_workers(width, log_n, 32);
1609                assert_eq!(alone.depth, shared.depth, "{alone:?}");
1610                assert_eq!(alone.log_block, shared.log_block, "{alone:?}");
1611            }
1612        }
1613    }
1614
1615    #[test]
1616    fn the_worker_count_decides_whether_a_narrow_row_is_gathered() {
1617        // The production width-1 shape, at the height whose stages a tile would fuse. One
1618        // worker leaves every stage above the contiguous tile as a plain pass, and the run
1619        // length it would have used does not matter, because no tile is gathered.
1620        let alone = Plan::for_workers(1, 20, 1);
1621        assert_eq!(alone.depth, 0, "{alone:?}");
1622        assert_eq!(alone.leftover(), alone.log_n - alone.local, "{alone:?}");
1623
1624        // At the threshold the tile appears, and the stages above the contiguous tile fuse.
1625        let shared = Plan::for_workers(1, 20, STAGED_WORKERS);
1626        assert!(shared.depth >= 2, "{shared:?}");
1627        assert_eq!(shared.leftover(), 0, "{shared:?}");
1628
1629        // Only the staging depth turns on the worker count; the rest of the cut does not.
1630        assert_eq!(alone.local, shared.local);
1631        assert_eq!(alone.log_block, shared.log_block);
1632    }
1633
1634    #[test]
1635    fn every_width_matches_the_reference_transform() {
1636        // The oracle depends on none of the identities the fast transform is built from, so
1637        // it pins the map itself rather than the schedule. A zero shift makes the first stage
1638        // twiddle of every block the domain point alone, which is the one case an off-by-one
1639        // in the shift table survives.
1640        let poly = PolyBasisNtt::default();
1641        let naive = NaiveAdditiveNtt::<BinaryField128>::default();
1642        for width in ORACLE_WIDTHS {
1643            for log_n in 0..=REFERENCE_LOG_N {
1644                for shift in [BinaryField128::ZERO, test_shift()] {
1645                    let coeffs = matrix(log_n, width, 3);
1646                    let evals = naive.shifted_ntt_batch(coeffs.clone(), shift);
1647                    assert_eq!(
1648                        poly.shifted_ntt_batch(coeffs.clone(), shift),
1649                        evals,
1650                        "ntt width={width} log_n={log_n} shift={shift:?}"
1651                    );
1652                    assert_eq!(
1653                        poly.shifted_intt_batch(evals, shift),
1654                        coeffs,
1655                        "intt width={width} log_n={log_n} shift={shift:?}"
1656                    );
1657                }
1658            }
1659        }
1660    }
1661
1662    #[test]
1663    fn padded_transform_matches_the_tower_at_every_width() {
1664        // The padded entry point plans one coset and reuses that plan for all of them, so a
1665        // run length suiting the full height but not the message height shows up here. The
1666        // tower transform stands in for the oracle, which these message heights are past, and
1667        // `crate::lch`'s `check_matches_naive` is what pins it to the oracle.
1668        let poly = PolyBasisNtt::default();
1669        let tower = LchNtt::<BinaryField128>::default();
1670        for width in ORACLE_WIDTHS {
1671            for log_message in [0, 1, 5, 9] {
1672                for log_inv_rate in 0..=3 {
1673                    let mut mat = matrix(log_message, width, 23);
1674                    mat.values
1675                        .resize(mat.values.len() << log_inv_rate, BinaryField128::ZERO);
1676                    let expected = tower.ntt_batch(mat.clone());
1677                    assert_eq!(
1678                        poly.ntt_batch_padded(mat, log_inv_rate),
1679                        expected,
1680                        "width={width} log_message={log_message} rate={log_inv_rate}"
1681                    );
1682                }
1683            }
1684        }
1685    }
1686
1687    #[test]
1688    fn folded_conversions_match_standalone_conversion_passes() {
1689        // Invariant: a basis conversion carried by whichever phase first reads or last writes
1690        // an element is the same map as a standalone pass over the whole matrix before or
1691        // after the transform.
1692        //
1693        // The cut points decide which phase carries it — a staging group's gather, a
1694        // staging group's scatter, the contiguous tile, or a pass of its own — so the same
1695        // set of cuts as the schedule test runs here.
1696        //
1697        // A run of three or more groups is what shows that the entry conversion rides the
1698        // first group only and the exit conversion the last group only.
1699        for plan in cut_plans() {
1700            let Plan { width, log_n, .. } = plan;
1701            for inverse in [false, true] {
1702                let input = coefficients(log_n, width);
1703
1704                // Conversion in, then the schedule.
1705                let mut expected = input.clone();
1706                super::convert(&mut expected, super::INTO_POLY);
1707                scheduled(&mut expected, plan, inverse, NONE);
1708                let mut actual = input.clone();
1709                scheduled(&mut actual, plan, inverse, Fold::ENTRY);
1710                assert_eq!(actual, expected, "entry {plan:?} inverse={inverse}");
1711
1712                // Conversion in, the schedule, conversion out.
1713                super::convert(&mut expected, super::INTO_TOWER);
1714                let mut actual = input.clone();
1715                scheduled(&mut actual, plan, inverse, Fold::BOTH);
1716                assert_eq!(actual, expected, "both {plan:?} inverse={inverse}");
1717
1718                // The schedule, then conversion out.
1719                let mut expected = input.clone();
1720                scheduled(&mut expected, plan, inverse, NONE);
1721                super::convert(&mut expected, super::INTO_TOWER);
1722                let mut actual = input;
1723                scheduled(&mut actual, plan, inverse, Fold::EXIT);
1724                assert_eq!(actual, expected, "exit {plan:?} inverse={inverse}");
1725            }
1726        }
1727    }
1728
1729    #[test]
1730    fn a_first_group_shared_by_every_coset_matches_each_coset_alone() {
1731        // Invariant: every coset of a padded message starts from the same coefficients, so
1732        // gathering them once for the first group of all cosets changes the traffic alone,
1733        // and every coset comes out as its own transform of the message would.
1734        for plan in cut_plans() {
1735            let Plan { width, log_n, .. } = plan;
1736            let Some(depth) = plan.group_sizes().next() else {
1737                continue;
1738            };
1739            let message = coefficients(log_n, width);
1740            let len = message.len();
1741            for log_cosets in 0..=2 {
1742                let twiddles: Vec<_> = (0..1 << log_cosets)
1743                    .map(|c| super::Twiddles::new(log_n, domain_point(c << log_n)))
1744                    .collect();
1745                let mut actual = message.clone();
1746                actual.resize(len << log_cosets, 0);
1747                super::first_group_into_cosets(&mut actual, len, plan, depth, &twiddles);
1748                for (c, coset) in actual.chunks_mut(len).enumerate() {
1749                    super::forward_below(coset, plan, &twiddles[c], 1, Fold::EXIT);
1750                }
1751
1752                for (c, coset) in actual.chunks(len).enumerate() {
1753                    let mut expected = message.clone();
1754                    super::forward(&mut expected, plan, domain_point(c << log_n), Fold::BOTH);
1755                    assert_eq!(coset, &expected[..], "{plan:?} coset={c}");
1756                }
1757            }
1758        }
1759    }
1760
1761    #[test]
1762    fn padded_transform_matches_the_tower_where_cosets_share_their_first_group() {
1763        // The padded entry point shares a first group only above a length that grows with the
1764        // worker count, and a single column stages one only from a worker count up. Both are
1765        // the host's, so the shared path is driven directly, under the plan a pinned count of
1766        // workers gets, rather than through the entry point.
1767        //
1768        // Rows of four and sixteen elements fill a cache line, and a single column is staged
1769        // from `STAGED_WORKERS` up, as it commits.
1770        let tower = LchNtt::<BinaryField128>::default();
1771        for (width, log_message) in [(4, 14), (16, 12), (1, 17)] {
1772            let plan = Plan::for_workers(width, log_message, STAGED_WORKERS);
1773            let depth = plan
1774                .group_sizes()
1775                .next()
1776                .expect("every shape here stages a first group");
1777            for log_inv_rate in 1..=2 {
1778                let mut mat = matrix(log_message, width, 29);
1779                mat.values
1780                    .resize(mat.values.len() << log_inv_rate, BinaryField128::ZERO);
1781                let expected = tower.ntt_batch(mat.clone());
1782
1783                let mut values: Vec<u128> = mat.values.iter().map(|v| v.to_repr()).collect();
1784                super::padded_sharing_first_group(&mut values, plan, depth, log_inv_rate);
1785                let actual: Vec<_> = values.into_iter().map(BinaryField128::from_repr).collect();
1786                assert_eq!(
1787                    actual, expected.values,
1788                    "width={width} log_message={log_message} rate={log_inv_rate}"
1789                );
1790            }
1791        }
1792    }
1793
1794    #[test]
1795    fn padded_transform_matches_naive_at_wide_widths() {
1796        for width in [1, 4, 16, 64] {
1797            for added in [0, 1, 2, 3] {
1798                let mut mat = matrix(4, width, 13);
1799                mat.values
1800                    .resize(mat.values.len() << added, BinaryField128::ZERO);
1801                let expected = NaiveAdditiveNtt::default().ntt_batch(mat.clone());
1802                assert_eq!(
1803                    PolyBasisNtt::default().ntt_batch_padded(mat, added),
1804                    expected
1805                );
1806            }
1807        }
1808    }
1809
1810    #[test]
1811    fn shifted_lde_matches_naive_at_wide_widths() {
1812        for width in [1, 4, 16, 64] {
1813            for added in [0, 1, 2, 3] {
1814                let mat = matrix(4, width, 17);
1815                let shift = BinaryField128::from_repr(1 << 127);
1816                let expected =
1817                    NaiveAdditiveNtt::default().shifted_lde_batch(mat.clone(), added, shift);
1818                let actual = PolyBasisNtt::default().shifted_lde_batch(mat.clone(), added, shift);
1819                assert_eq!(actual, expected);
1820                assert_eq!(&actual.values[..mat.values.len()], &mat.values);
1821            }
1822        }
1823    }
1824
1825    #[test]
1826    #[should_panic = "extended codeword length overflows usize"]
1827    fn lde_rejects_length_overflow() {
1828        let _ = PolyBasisNtt::default().lde_batch(matrix(1, 1, 0), usize::BITS as usize - 1);
1829    }
1830
1831    #[test]
1832    fn incremental_twiddles_match_independent_domain_points() {
1833        let shift = BinaryField128::from_repr((1 << 127) | 123);
1834        let twiddles = super::Twiddles::new(usize::BITS as usize - 1, shift);
1835        for stage in [0, 1, 7, 15, 31]
1836            .into_iter()
1837            .filter(|&stage| stage < usize::BITS as usize - 1)
1838        {
1839            for start in [0, 1, 63, 127, (1usize << (usize::BITS - 3)) - 3] {
1840                let mut t = twiddles.at(stage, start);
1841                for block in start..start + 9 {
1842                    let expected = subspace_polynomial(stage, shift)
1843                        + domain_point::<BinaryField128>(block << 1);
1844                    assert_eq!(t, poly_basis::from_tower(expected));
1845                    t ^= twiddles.deltas[block.trailing_ones() as usize];
1846                }
1847            }
1848        }
1849    }
1850
1851    #[test]
1852    fn transforms_cross_cache_boundaries_in_natural_order() {
1853        for width in [1, 3, 16, 64] {
1854            let mat = matrix(12, width, 29);
1855            let shift = BinaryField128::from_repr((1 << 127) | 7919);
1856            let expected = crate::LchNtt::default().shifted_ntt_batch(mat.clone(), shift);
1857            let actual = PolyBasisNtt::default().shifted_ntt_batch(mat.clone(), shift);
1858            assert_eq!(actual, expected);
1859            assert_eq!(
1860                PolyBasisNtt::default().shifted_intt_batch(actual, shift),
1861                mat
1862            );
1863        }
1864    }
1865
1866    proptest! {
1867        #![proptest_config(ProptestConfig::with_cases(32))]
1868
1869        /// The polynomial-basis transform is the same map as the reference oracle.
1870        #[test]
1871        fn poly_basis_matches_naive(
1872            log_n in 0usize..=8,
1873            width in 1usize..=8,
1874            seed in any::<u64>(),
1875            shift in any::<u64>(),
1876        ) {
1877            let coeffs = matrix(log_n, width, seed);
1878            let shift = BinaryField128::from_le_byte_iter(
1879                shift.to_le_bytes().into_iter().cycle(),
1880            );
1881
1882            let fast = PolyBasisNtt::default().shifted_ntt_batch(coeffs.clone(), shift);
1883            let slow = NaiveAdditiveNtt::<BinaryField128>::default()
1884                .shifted_ntt_batch(coeffs, shift);
1885            prop_assert_eq!(fast, slow);
1886        }
1887
1888        #[test]
1889        fn poly_basis_round_trips(
1890            log_n in 0usize..=8,
1891            width in 1usize..=8,
1892            seed in any::<u64>(),
1893            shift in any::<u64>(),
1894        ) {
1895            let coeffs = matrix(log_n, width, seed);
1896            let shift = BinaryField128::from_le_byte_iter(
1897                shift.to_le_bytes().into_iter().cycle(),
1898            );
1899            let ntt = PolyBasisNtt::default();
1900            let evals = ntt.shifted_ntt_batch(coeffs.clone(), shift);
1901            prop_assert_eq!(ntt.shifted_intt_batch(evals, shift), coeffs);
1902        }
1903
1904        /// `PolyBasisNtt`'s low-degree extension agrees with the oracle's, on a coset too, and
1905        /// the input rows reappear as the prefix: the correspondence Phase 3 folds along.
1906        #[test]
1907        fn poly_basis_lde_matches_naive(
1908            log_n in 0usize..=6,
1909            added in 0usize..=3,
1910            width in 1usize..=3,
1911            seed in any::<u64>(),
1912            shift in any::<u64>(),
1913        ) {
1914            let coeffs = matrix(log_n, width, seed);
1915            let shift = BinaryField128::from_le_byte_iter(
1916                shift.to_le_bytes().into_iter().cycle(),
1917            );
1918
1919            let lde = PolyBasisNtt::default().shifted_lde_batch(coeffs.clone(), added, shift);
1920            let naive = NaiveAdditiveNtt::<BinaryField128>::default()
1921                .shifted_lde_batch(coeffs.clone(), added, shift);
1922            prop_assert_eq!(&lde, &naive);
1923            prop_assert_eq!(&lde.values[..coeffs.values.len()], &coeffs.values[..]);
1924        }
1925    }
1926
1927    #[test]
1928    fn poly_basis_matches_the_tower_across_several_tasks() {
1929        // These heights take more than one butterfly task per stage, so a task seeds its
1930        // twiddle at a block index of its own rather than at zero. Every oracle test sits
1931        // below them, so `LchNtt` stands in for the oracle here.
1932        //
1933        // An independent twiddle walk holds `LchNtt` itself at these heights, and it keeps its
1934        // data in the tower basis and derives its twiddles separately, so it shares no
1935        // arithmetic with the transform under test.
1936        let poly = PolyBasisNtt::default();
1937        let tower = LchNtt::<BinaryField128>::default();
1938        for width in ORACLE_WIDTHS {
1939            // The tall height is where the plan cuts deepest, and a tower transform of a
1940            // tall wide matrix is the most expensive thing here, so the narrow widths carry
1941            // it, where a run spans the most rows. The per-stage schedule covers every width
1942            // at every height regardless.
1943            let heights: &[usize] = if width <= 4 {
1944                &[7, 10, MAX_LOG_N]
1945            } else {
1946                &[7, 10]
1947            };
1948            for &log_n in heights {
1949                for shift_bits in [0u64, 0x1234_5678_9abc_def0] {
1950                    let coeffs = matrix(log_n, width, 5);
1951                    let shift = BinaryField128::from_le_byte_iter(
1952                        shift_bits.to_le_bytes().into_iter().cycle(),
1953                    );
1954
1955                    let evals = tower.shifted_ntt_batch(coeffs.clone(), shift);
1956                    assert_eq!(
1957                        poly.shifted_ntt_batch(coeffs.clone(), shift),
1958                        evals,
1959                        "ntt width={width} log_n={log_n} shift={shift_bits:#x}"
1960                    );
1961                    assert_eq!(
1962                        poly.shifted_intt_batch(evals, shift),
1963                        coeffs,
1964                        "intt width={width} log_n={log_n} shift={shift_bits:#x}"
1965                    );
1966                }
1967            }
1968        }
1969    }
1970
1971    /// A height that is not a power of two has no well-defined `ℓ`. `ℓ` exceeding the bit width
1972    /// of `BinaryField128` is covered generically at [`LchNtt`](crate::LchNtt), where the level
1973    /// is a type parameter and the panic is cheap to reach; at a fixed `BinaryField128` it is
1974    /// only reachable through a matrix of `2^129` rows, which is not a test worth writing.
1975    #[test]
1976    #[should_panic]
1977    fn shifted_ntt_batch_rejects_a_non_power_of_two_height() {
1978        let coeffs = RowMajorMatrix::new(matrix(0, 1, 0).values.repeat(3), 1);
1979        let _ = PolyBasisNtt::default().ntt_batch(coeffs);
1980    }
1981}