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