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}