Skip to main content

par2_rs/create/
encode.rs

1//! Forward PAR2 recovery-data encoding.
2//!
3//! The encoder keeps the recovery output in output-major order while it walks
4//! each input slice in bounded, stride-aligned stripes.  Input batches rotate
5//! through two staging areas, so the arithmetic path can accumulate a complete
6//! output stripe without requiring a source-sized working allocation.
7//!
8//! Accumulation and output finishing split the recovery outputs into
9//! contiguous bands, one rayon task per band.  Bands write disjoint
10//! output-major regions and never share mutable state, so the produced
11//! recovery bytes are identical at every band count.
12
13use std::mem::size_of;
14
15use crate::error::{Par2Error, Result};
16use crate::gf;
17use crate::types::{
18    CancellationToken, MAX_TOTAL_INPUT_SLICES, ProgressCallback, ProgressPhase, ProgressStage,
19    ProgressUpdate, RecoveryExponent,
20};
21use reedsolomon_rs::gf_simd::{self, PreparedFactorSrc};
22
23use super::plan::default_memory_limit;
24use super::transform::{self, TransformPolicy};
25
26pub(crate) use super::transform::EncodeAttempt;
27
28/// Sources per input batch for the families whose kernels take one slice per
29/// source in fixed-size groups on x86 (the folded pair kernels take two groups
30/// of six; the packed XOR-JIT is built for twelve regions).
31const DEFAULT_INPUT_GROUPING: usize = 12;
32/// Sources per input batch for the aarch64 CLMUL family. Its kernel folds
33/// eight sources into the destination per pass, so twelve inputs cost a full
34/// pass plus a half-empty one whose per-block reduction and destination
35/// traffic are amortized over only four sources; sixteen is two full passes.
36/// This is the reference's own batching rule (`inputBatchSize = 12 +
37/// idealInputMultiple/2`, rounded down to a multiple of `idealInputMultiple`,
38/// which is 8 for CLMUL_NEON/SHA3) — a fact about the kernel's group shape,
39/// not about any core.
40#[cfg_attr(not(target_arch = "aarch64"), allow(dead_code))]
41const CLMUL_INPUT_GROUPING: usize = 16;
42/// Upper bound on any family's input grouping: sizes the fixed per-row arrays
43/// (coefficient rows, prepared-source descriptors) that must not touch the
44/// heap per output row.
45const MAX_INPUT_GROUPING: usize = 16;
46const _: () = assert!(DEFAULT_INPUT_GROUPING <= MAX_INPUT_GROUPING);
47const _: () = assert!(CLMUL_INPUT_GROUPING <= MAX_INPUT_GROUPING);
48/// Default depth of the per-stripe staging hand-off ring.
49///
50/// The producer fills area `batch_index % depth` and may run `depth - 1`
51/// batches ahead of the slowest band. Two is the minimum that overlaps the
52/// fill with the arithmetic at all, and was the shipped depth while the fill
53/// was only a read and a layout conversion.
54///
55/// It is no longer enough. The fill now also hashes the bytes it reads (the
56/// source digests the critical packets need), which makes the producer a
57/// thread with real work on it, and the pass runs one band worker per host
58/// thread — so the producer is the `+1` on a saturated machine and gets
59/// descheduled. At depth two a descheduled producer starves every band
60/// immediately, because the one area it has not filled is the one they need
61/// next. Measured on an 18-thread host, 256 MiB over 4096 sources: the fused
62/// hashing costs 0.51 s on one thread and the bands 4.3 ms per batch, so the
63/// producer is four times faster than it needs to be — yet at depth two the
64/// pass paid 0.32 s of it, and at six bands (no oversubscription, same
65/// producer, same hashing) it paid 0.02-0.07 s. Depth is the difference: with
66/// slack the bands ride through a preemption instead of stopping at it.
67///
68/// Each extra area costs one input batch of staging (about 1 MiB at the
69/// 64 KiB-slice create shape, against a ~53 MiB recovery stripe), and
70/// `Par2MemoryPlan` counts every one of them.
71const DEFAULT_STAGING_AREA_COUNT: usize = 4;
72/// Bound on the ring depth, so a hatch value cannot turn the staging plan into
73/// an unbounded multiple of the stripe.
74const MAX_STAGING_AREA_COUNT: usize = 8;
75const _: () = assert!(DEFAULT_STAGING_AREA_COUNT >= 2);
76const _: () = assert!(DEFAULT_STAGING_AREA_COUNT <= MAX_STAGING_AREA_COUNT);
77
78/// Depth of the staging hand-off ring. `WEAVER_PAR2_CREATE_AREAS=N` (2..=8)
79/// pins it so the depths can be A/B'd from one binary (same escape-hatch
80/// pattern as `WEAVER_PAR2_CREATE_THREADS`); unset, `0`, or out of range means
81/// [`DEFAULT_STAGING_AREA_COUNT`].
82///
83/// Process-stable by construction, and read through this one function by both
84/// [`BufferPlan`] and the encoder, so the memory a plan admits is the memory
85/// the pass allocates.
86fn configured_staging_areas() -> usize {
87    static CONFIGURED: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
88    *CONFIGURED.get_or_init(|| {
89        std::env::var("WEAVER_PAR2_CREATE_AREAS")
90            .ok()
91            .and_then(|value| value.trim().parse::<usize>().ok())
92            .filter(|&areas| (2..=MAX_STAGING_AREA_COUNT).contains(&areas))
93            .unwrap_or(DEFAULT_STAGING_AREA_COUNT)
94    })
95}
96
97/// Consecutive sources staged into the transfer buffer at once, and therefore
98/// the widest multi-buffer digest a [`ForwardSourceObserver`] can run over the
99/// feed.
100///
101/// The multi-buffer MD5 kernel's own lane count, clamped to one input batch:
102/// staging a wider run than the kernel can hash buys nothing, and staging a
103/// narrower one would make the fused source hashing fall back to one message
104/// per pass — measured on x86 as roughly a 4x difference in per-slice digest
105/// cost. Process-stable (the detection is cached per ISA) and read through
106/// this one function by both [`BufferPlan`] and [`fill_staging`], so the plan
107/// and the pass it admits always size the buffer the same way.
108fn transfer_group_lanes() -> usize {
109    static CONFIGURED: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
110    *CONFIGURED.get_or_init(|| crate::md5_simd::max_lanes().clamp(1, MAX_INPUT_GROUPING))
111}
112
113/// Folded coefficient groups covered by one output row, bounding the stack
114/// reference tables in `accumulate_band`. The folded family's
115/// [`KernelContract`] always uses [`DEFAULT_INPUT_GROUPING`], so this is the
116/// exact group count, not a worst case; the arm still checks before slicing.
117#[cfg(target_arch = "x86_64")]
118const MAX_FOLDED_GROUPS: usize = DEFAULT_INPUT_GROUPING / gf_simd::FOLDED_GROUP;
119
120/// Worker bands used by forward accumulation. `WEAVER_PAR2_CREATE_THREADS=N`
121/// pins the band count (1 = the sequential pre-banding behavior) so the two
122/// shapes can be A/B'd without a rebuild (same escape-hatch pattern as
123/// `WEAVER_GF16_FOLDED_AVX512`); unset or `0` follows the host CPU count.
124///
125/// The resolved value is process-stable by construction: it must not read
126/// `rayon::current_num_threads()`, whose answer is pool-relative and would
127/// make the plan's memory accounting differ between a caller's rayon worker
128/// and the main thread (breaking `Par2CreatePlan` equality), and whose first
129/// call would eagerly spawn the global pool from plan-only paths. Bands
130/// therefore follow `available_parallelism`.
131///
132/// Forward accumulation now runs one scoped OS thread per band (see
133/// [`encode_stripe_banded`] for why a work-stealing pool cannot host a
134/// blocking producer/consumer ring), so this is literally the worker count of
135/// a create pass rather than only a partitioning width — a deliberately huge
136/// `WEAVER_PAR2_CREATE_THREADS` now costs that many threads per stripe.
137/// Source hashing and staged-volume validation still run on rayon.
138pub(crate) fn configured_create_threads() -> usize {
139    static CONFIGURED: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
140    *CONFIGURED.get_or_init(|| {
141        // Single-threaded wasm (`wasm32-wasip1`) has no worker pool at all;
142        // keep rayon machinery untouched there, exactly as before. On
143        // `wasm32-wasip1-threads` the probe reports `true` and the normal
144        // resolution below applies — including `WEAVER_PAR2_CREATE_THREADS`,
145        // which is how an embedder states the host width, because
146        // `available_parallelism()` answers `Ok(1)` under wasi (the guest
147        // cannot see the host's core count) and would otherwise pin the
148        // banding to 1 on a perfectly capable threaded runtime.
149        if !reedsolomon_rs::threading::parallel_enabled() {
150            return 1;
151        }
152        std::env::var("WEAVER_PAR2_CREATE_THREADS")
153            .ok()
154            .and_then(|value| value.trim().parse::<usize>().ok())
155            .filter(|&threads| threads != 0)
156            .unwrap_or_else(|| {
157                std::thread::available_parallelism()
158                    .map(std::num::NonZeroUsize::get)
159                    .unwrap_or(1)
160            })
161    })
162}
163
164/// Input grouping for the slice-per-source families that have no structural
165/// group size (`Portable`, `Simd`): the CLMUL grouping on aarch64, the default
166/// elsewhere. `WEAVER_PAR2_CREATE_GROUPING=N` (1..=16) pins it so the two
167/// batch shapes can be A/B'd from one binary (same escape-hatch pattern as
168/// `WEAVER_PAR2_CREATE_THREADS`); unset, `0`, or out of range means the
169/// family default. Process-stable by construction: the staging plan and the
170/// batch loop must agree.
171fn configured_input_grouping() -> usize {
172    static CONFIGURED: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
173    *CONFIGURED.get_or_init(|| {
174        #[cfg(target_arch = "aarch64")]
175        let family_default = CLMUL_INPUT_GROUPING;
176        #[cfg(not(target_arch = "aarch64"))]
177        let family_default = DEFAULT_INPUT_GROUPING;
178        std::env::var("WEAVER_PAR2_CREATE_GROUPING")
179            .ok()
180            .and_then(|value| value.trim().parse::<usize>().ok())
181            .filter(|&grouping| (1..=MAX_INPUT_GROUPING).contains(&grouping))
182            .unwrap_or(family_default)
183    })
184}
185
186/// Source lanes the `Simd` family block-interleaves into one contiguous
187/// staging stream, so that one kernel pass reads one sequential run instead of
188/// one region per source.
189///
190/// The aarch64 CLMUL pass folds [`gf_simd::INPUT_BATCH_INTERLEAVE_LANES`]
191/// sources into the destination at a shared block offset. Laid out lane-major
192/// that is eight source lines plus a destination line competing for one L1D
193/// set per block, which no stride residue can make fit a 2-way set — Cortex-A72
194/// kept 26 L1D refills per thousand instructions after the lane/row skew that
195/// took Neoverse N1's 4-way L1D from 35 to 3. Interleaved, the same pass reads
196/// one stream: two streams total with the destination, which any associativity
197/// holds. The x86 folded family has always done this (`split_encode_scatter`,
198/// six lanes at 32 B) and never aliased.
199///
200/// `WEAVER_PAR2_CREATE_INTERLEAVE=N` pins the width so the layouts can be A/B'd
201/// without a rebuild (same escape-hatch pattern as
202/// `WEAVER_PAR2_CREATE_GROUPING`); `1` is the lane-major layout this pass
203/// shipped with. Widths below the kernel's own pass width also shorten the
204/// passes, so only `1`, the kernel width and the whole grouping compare
205/// like for like. Off aarch64 an interleaved width selects the portable
206/// reference kernel in `reedsolomon-rs`, which is a correctness path and not a
207/// fast one — the knob is for validating the layout there, not for running it.
208///
209/// Process-stable by construction, like every other layout input: the staging
210/// plan and the batch loop must agree.
211fn configured_interleave_lanes() -> usize {
212    static CONFIGURED: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
213    *CONFIGURED.get_or_init(|| {
214        std::env::var("WEAVER_PAR2_CREATE_INTERLEAVE")
215            .ok()
216            .and_then(|value| value.trim().parse::<usize>().ok())
217            .filter(|&lanes| (1..=MAX_INPUT_GROUPING).contains(&lanes))
218            .unwrap_or(gf_simd::INPUT_BATCH_INTERLEAVE_LANES)
219    })
220}
221
222/// Kernel granularity of the `Simd` family.
223///
224/// The block-interleaved layout is only expressible in whole
225/// [`gf_simd::INPUT_BATCH_BLOCK_BYTES`] blocks, so the stripe and every tile
226/// inside it must be a whole number of them; that is exactly what the family's
227/// stride is for. aarch64 keeps the block stride even when the interleave is
228/// pinned back to 1, so that pin isolates the layout and changes no plan
229/// number. Elsewhere the family stays at the scalar word it has always used
230/// unless the interleave is pinned on.
231fn simd_stride() -> usize {
232    if cfg!(target_arch = "aarch64") || configured_interleave_lanes() > 1 {
233        gf_simd::INPUT_BATCH_BLOCK_BYTES
234    } else {
235        2
236    }
237}
238
239/// Band shape for one encoding pass: `(band_size, band_count)` with
240/// `band_count = ceil(output_count / band_size)` exactly, so chunked splits,
241/// workspace counts, and memory admission all agree. Never zero-sized.
242fn create_band_shape(output_count: usize) -> (usize, usize) {
243    let outputs = output_count.max(1);
244    let target = configured_create_threads().clamp(1, outputs);
245    let band_size = outputs.div_ceil(target);
246    (band_size, outputs.div_ceil(band_size))
247}
248
249/// Forward working-set quantities used by both planning and encoding.
250#[derive(Clone, Copy, Debug, Eq, PartialEq)]
251pub(crate) struct ForwardMemoryEstimate {
252    pub(crate) factor_workspace_bytes: usize,
253    pub(crate) jit_workspace_bytes: usize,
254    pub(crate) stripe_buffer_bytes: usize,
255    pub(crate) processing_peak_bytes: usize,
256}
257
258/// Arithmetic path requested for forward encoding.
259///
260/// `Auto` follows the creation-specific runtime ladder (the oracle's:
261/// affine/shuffle families only).  The other variants are
262/// useful for deterministic validation and controlled
263/// benchmarking; an explicitly requested unavailable tier returns an error.
264#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
265pub enum ForwardKernel {
266    /// Select the best supported path for the current process.
267    #[default]
268    Auto,
269    /// Word-wise portable arithmetic.  This is the final non-SIMD fallback.
270    Portable,
271    /// Direct grouped GF(2^16) SIMD dispatch.
272    Simd,
273    /// AVX2 split-layout folded dispatch (GFNI, 512/256-bit shuffle2x).
274    #[cfg(target_arch = "x86_64")]
275    Folded,
276    /// Packed AVX2 XOR-JIT dispatch (fast-JIT CPUs without GFNI).
277    #[cfg(target_arch = "x86_64")]
278    XorJitAvx2,
279}
280
281/// Options controlling one forward encoding pass.
282pub struct ForwardEncoderOptions {
283    /// Maximum bytes retained by the stripe controller and active arithmetic
284    /// tier.  The default follows the creator's system-memory policy.
285    pub memory_limit: Option<usize>,
286    /// Cooperative cancellation shared with the caller.
287    pub cancel: Option<CancellationToken>,
288    /// Optional progress callback.  Updates use the existing long-running
289    /// operation progress shape and report the number of completed stripes.
290    pub progress: Option<ProgressCallback>,
291    /// Arithmetic path to use.
292    pub kernel: ForwardKernel,
293    /// Whether this pass may take the transform arm (see
294    /// [`super::transform`]). Creation resolves it from the environment once
295    /// per process; tests set it directly so they never race a global.
296    pub(crate) transform: TransformPolicy,
297}
298
299impl Default for ForwardEncoderOptions {
300    fn default() -> Self {
301        Self {
302            memory_limit: None,
303            cancel: None,
304            progress: None,
305            kernel: ForwardKernel::Auto,
306            transform: TransformPolicy::default(),
307        }
308    }
309}
310
311/// One complete recovery block produced by [`ForwardEncoder::encode`].
312#[cfg(test)]
313#[derive(Clone, Debug, Eq, PartialEq)]
314pub struct ForwardRecoveryBlock {
315    /// The PAR2 recovery exponent assigned to this block.
316    pub exponent: RecoveryExponent,
317    /// The recovery payload, exactly `slice_size` bytes long.
318    pub data: Vec<u8>,
319}
320
321/// Ordered destination for streamed recovery stripes.
322///
323/// Calls occur in increasing stripe offset and increasing output index order.
324/// A writer can therefore place each chunk directly into its recovery packet
325/// without retaining all recovery blocks in memory.
326pub trait ForwardRecoverySink {
327    /// Store one output stripe.
328    fn write_recovery_chunk(
329        &mut self,
330        output_index: usize,
331        exponent: RecoveryExponent,
332        offset: u64,
333        data: &[u8],
334    ) -> Result<()>;
335}
336
337/// Source-slice access used by the forward stripe controller.
338pub(crate) trait ForwardSourceProvider {
339    /// Number of logical source slices in encoder order.
340    fn source_count(&self) -> usize;
341
342    /// Length of one source slice before zero padding.
343    fn source_slice_len(&self, source_index: usize) -> Result<usize>;
344
345    /// Read a slice range into the supplied staging buffer.
346    fn read_source_chunk(
347        &mut self,
348        source_index: usize,
349        offset: usize,
350        destination: &mut [u8],
351    ) -> Result<usize>;
352}
353
354/// Observer of the exact source bytes the encode feed reads, in feed order.
355///
356/// The feed walks sources in increasing index and hands each source's bytes to
357/// the arithmetic exactly once per stripe, so a digest driven from here costs
358/// no second read of the file. Runs of consecutive slices arrive together so
359/// the observer can lane them through a multi-buffer kernel; the run is the
360/// encoder's own transfer group, never split across a call.
361///
362/// A per-file digest is only correct from here while the pass is
363/// single-stripe: with more than one stripe the feed is stripe-major, not file
364/// order (pinned by
365/// `the_feed_is_stripe_major_once_a_slice_needs_more_than_one_stripe`). The
366/// caller decides; the observer is told the source index and may reject an
367/// order it cannot serve.
368pub(crate) trait ForwardSourceObserver: Send {
369    /// One run of consecutive source slices, in increasing index, each with
370    /// its real (unpadded) bytes for this stripe.
371    fn observe_slices(&mut self, first_source_index: usize, slices: &[&[u8]]) -> Result<()>;
372}
373
374#[cfg(test)]
375struct InMemorySourceProvider<'a> {
376    sources: &'a [&'a [u8]],
377}
378
379#[cfg(test)]
380impl ForwardSourceProvider for InMemorySourceProvider<'_> {
381    fn source_count(&self) -> usize {
382        self.sources.len()
383    }
384
385    fn source_slice_len(&self, source_index: usize) -> Result<usize> {
386        self.sources
387            .get(source_index)
388            .map(|source| source.len())
389            .ok_or_else(|| invalid_input("source slice index is out of range"))
390    }
391
392    fn read_source_chunk(
393        &mut self,
394        source_index: usize,
395        offset: usize,
396        destination: &mut [u8],
397    ) -> Result<usize> {
398        let source = self
399            .sources
400            .get(source_index)
401            .ok_or_else(|| invalid_input("source slice index is out of range"))?;
402        let start = offset.min(source.len());
403        let take = destination.len().min(source.len().saturating_sub(start));
404        destination[..take].copy_from_slice(&source[start..start + take]);
405        Ok(take)
406    }
407}
408
409/// Forward PAR2 recovery encoder.
410#[derive(Clone, Debug)]
411pub struct ForwardEncoder {
412    slice_size: usize,
413    recovery_exponents: Vec<RecoveryExponent>,
414}
415
416impl ForwardEncoder {
417    /// Construct an encoder for one PAR2 slice size and ordered exponents.
418    pub fn new(slice_size: usize, recovery_exponents: Vec<RecoveryExponent>) -> Result<Self> {
419        if slice_size == 0 || !slice_size.is_multiple_of(4) {
420            return Err(invalid_input(format!(
421                "slice size must be a nonzero multiple of 4, got {slice_size}"
422            )));
423        }
424        if recovery_exponents.len() > u32::MAX as usize {
425            return Err(resource_limit("recovery output count exceeds u32"));
426        }
427        Ok(Self {
428            slice_size,
429            recovery_exponents,
430        })
431    }
432
433    /// The configured slice size.
434    #[cfg(test)]
435    pub fn slice_size(&self) -> usize {
436        self.slice_size
437    }
438
439    /// Return the CPU paths available in this process.
440    #[cfg(test)]
441    pub fn available_kernels() -> Vec<ForwardKernel> {
442        let kernels = vec![ForwardKernel::Portable, ForwardKernel::Simd];
443        #[cfg(target_arch = "x86_64")]
444        {
445            let mut kernels = kernels;
446            let capabilities = runtime_kernel_capabilities();
447            if capabilities.folded {
448                kernels.push(ForwardKernel::Folded);
449            }
450            if capabilities.avx2_jit {
451                kernels.push(ForwardKernel::XorJitAvx2);
452            }
453            kernels
454        }
455        #[cfg(not(target_arch = "x86_64"))]
456        kernels
457    }
458
459    /// Resolve the automatic runtime choice without starting an encoding pass.
460    #[cfg(test)]
461    pub fn selected_kernel(&self, requested: ForwardKernel) -> Result<ForwardKernel> {
462        resolve_kernel_with_capabilities(requested, runtime_kernel_capabilities())
463            .map(public_kernel)
464    }
465
466    /// Encode all recovery blocks into memory.
467    #[cfg(test)]
468    pub fn encode(
469        &self,
470        sources: &[&[u8]],
471        options: &ForwardEncoderOptions,
472    ) -> Result<Vec<ForwardRecoveryBlock>> {
473        let mut sink = VecRecoverySink::new(&self.recovery_exponents, self.slice_size);
474        let mut provider = InMemorySourceProvider { sources };
475        self.encode_to(&mut provider, options, &mut sink)?;
476        Ok(sink.blocks)
477    }
478
479    /// Encode in-memory source slices through an ordered, bounded sink.
480    #[cfg(test)]
481    pub fn encode_slices_to<S: ForwardRecoverySink>(
482        &self,
483        sources: &[&[u8]],
484        options: &ForwardEncoderOptions,
485        sink: &mut S,
486    ) -> Result<()> {
487        let mut provider = InMemorySourceProvider { sources };
488        self.encode_to(&mut provider, options, sink)
489    }
490
491    /// Encode provider-backed source slices through an ordered, bounded sink.
492    #[cfg(test)]
493    pub fn encode_to<P: ForwardSourceProvider + ?Sized, S: ForwardRecoverySink>(
494        &self,
495        provider: &mut P,
496        options: &ForwardEncoderOptions,
497        sink: &mut S,
498    ) -> Result<()> {
499        self.encode_to_observed(provider, options, sink, None)
500    }
501
502    /// Encode as [`Self::encode_to`], driving `observer` from the same source
503    /// bytes the arithmetic reads. See [`ForwardSourceObserver`] for what the
504    /// feed order does and does not allow an observer to compute.
505    #[cfg(test)]
506    pub(crate) fn encode_to_observed<P: ForwardSourceProvider + ?Sized, S: ForwardRecoverySink>(
507        &self,
508        provider: &mut P,
509        options: &ForwardEncoderOptions,
510        sink: &mut S,
511        observer: Option<&mut dyn ForwardSourceObserver>,
512    ) -> Result<()> {
513        match self.encode_attempt(provider, options, sink, observer)? {
514            EncodeAttempt::Complete => Ok(()),
515            EncodeAttempt::TransformProbeMismatch => Err(resource_limit(
516                "transform recovery arithmetic disagreed with the dense definition",
517            )),
518        }
519    }
520
521    /// [`Self::encode_to_observed`], reporting a transform-arm safety-row
522    /// mismatch to the caller instead of turning it into an error: the create
523    /// path answers that by recreating every volume from the dense arm.
524    pub(crate) fn encode_attempt<P: ForwardSourceProvider + ?Sized, S: ForwardRecoverySink>(
525        &self,
526        provider: &mut P,
527        options: &ForwardEncoderOptions,
528        sink: &mut S,
529        observer: Option<&mut dyn ForwardSourceObserver>,
530    ) -> Result<EncodeAttempt> {
531        let mut observer = observer;
532        let observer = &mut observer;
533        validate_provider(provider, self.slice_size)?;
534        check_cancel(options)?;
535
536        if self.recovery_exponents.is_empty() {
537            return Ok(EncodeAttempt::Complete);
538        }
539
540        let memory_limit = options.memory_limit.unwrap_or_else(default_memory_limit);
541        let (kernel, buffers) = select_kernel_for_memory(
542            self.slice_size,
543            self.recovery_exponents.len(),
544            provider.source_count(),
545            memory_limit,
546            options.kernel,
547        )?;
548
549        // The transform arm is admitted against the smaller of the caller's
550        // budget and what the dense arm just reserved, so taking it can only
551        // lower this pass's residency, never raise it.
552        if let Some(arm) = transform::admit(
553            self.slice_size,
554            provider.source_count(),
555            &self.recovery_exponents,
556            memory_limit.min(buffers.memory_bytes),
557            options.transform,
558        ) && (observer.is_none() || arm.shape().passes == 1)
559        {
560            return transform::encode(
561                &arm,
562                self.slice_size,
563                &self.recovery_exponents,
564                provider,
565                options,
566                sink,
567                match observer.as_mut() {
568                    Some(observer) => Some(&mut **observer),
569                    None => None,
570                },
571            );
572        }
573
574        let contract = KernelContract::for_kernel(kernel);
575
576        let factors = FactorSource::new(provider.source_count());
577
578        // Held behind `Arc` so one filled area can be handed to every band
579        // worker for the duration of a batch and reclaimed for refilling by
580        // `Arc::get_mut` once they have all let go — the hand-off is the
581        // ownership, with no aliasing of a mutable buffer anywhere.
582        let staging_areas = configured_staging_areas();
583        let mut staging: Vec<std::sync::Arc<AlignedBuffer>> = (0..staging_areas)
584            .map(|_| std::sync::Arc::new(AlignedBuffer::new(buffers.staging_bytes)))
585            .collect();
586        // One raw batch per ring slot, reclaimed by the same `Arc::get_mut`
587        // proof the staged areas use: a slot must stay live until its batch
588        // has been both accumulated and hashed, which is exactly when every
589        // band has dropped that batch's ticket.
590        let mut transfers: Vec<std::sync::Arc<TransferSlot>> = (0..staging_areas)
591            .map(|_| std::sync::Arc::new(TransferSlot::new(buffers.transfer_bytes)))
592            .collect();
593        let mut output = AlignedBuffer::new(buffers.output_bytes);
594
595        let (band_size, band_count) = create_band_shape(self.recovery_exponents.len());
596        #[cfg(not(target_arch = "x86_64"))]
597        let _ = band_count;
598        #[cfg(target_arch = "x86_64")]
599        let mut jit_workspaces: Vec<reedsolomon_rs::xor_jit::packed::PackedJitWorkspace> =
600            (0..band_count).map(|_| Default::default()).collect();
601        #[cfg(target_arch = "x86_64")]
602        let jit_code_budget = buffers.jit_build_limit_bytes;
603
604        let stripe_count = self.slice_size.div_ceil(buffers.chunk_len);
605        let stripe_count_u32 = u32::try_from(stripe_count)
606            .map_err(|_| resource_limit("stripe count exceeds progress range"))?;
607        let total_bytes = (self.recovery_exponents.len() as u64)
608            .checked_mul(self.slice_size as u64)
609            .ok_or_else(|| resource_limit("progress byte count overflow"))?;
610
611        // One dispatch per stripe. The band workers are started once for the
612        // stripe and walk every input batch themselves; this thread is the
613        // producer, filling the staging ring ahead of them. The ring is what
614        // bounds the hand-off: the producer may run `staging_areas - 1`
615        // batches ahead of the slowest band and no further, which is the same
616        // two-stage overlap the previous per-batch `rayon::in_place_scope`
617        // gave, minus one scope entry and one band fan-out per input batch
618        // (342 of each per stripe on the 4096-source create shape).
619        //
620        // `banded` is false exactly when banding is off (single-threaded wasm
621        // and the `WEAVER_PAR2_CREATE_THREADS=1` escape hatch); the sequential
622        // arm performs the identical operation order on one thread, so the
623        // produced bytes cannot differ between the arms.
624        let batch_starts: Vec<usize> = (0..provider.source_count())
625            .step_by(contract.input_grouping)
626            .collect();
627        let banded = band_size < self.recovery_exponents.len();
628
629        let mut stripe_offset = 0usize;
630        let mut stripe_index = 0usize;
631        while stripe_offset < self.slice_size {
632            check_cancel(options)?;
633            let actual_len = (self.slice_size - stripe_offset).min(buffers.chunk_len);
634            let aligned_len = round_up(actual_len, contract.stride)?;
635            if banded {
636                encode_stripe_banded(
637                    kernel,
638                    provider,
639                    options,
640                    contract,
641                    &factors,
642                    &self.recovery_exponents,
643                    &mut staging,
644                    &mut transfers,
645                    &mut output.as_bytes_mut()[..buffers.output_bytes],
646                    &batch_starts,
647                    StripeGeometry {
648                        stripe_offset,
649                        actual_len,
650                        aligned_len,
651                        output_stride: buffers.row_stride,
652                    },
653                    band_size,
654                    #[cfg(target_arch = "x86_64")]
655                    &mut jit_workspaces,
656                    #[cfg(target_arch = "x86_64")]
657                    jit_code_budget,
658                    match observer.as_mut() {
659                        Some(observer) => Some(&mut **observer),
660                        None => None,
661                    },
662                )?;
663            } else {
664                output.as_bytes_mut()[..buffers.output_bytes].fill(0);
665                let mut slice_lens = [0usize; MAX_INPUT_GROUPING];
666                let source_count = provider.source_count();
667                if let Some(&first_start) = batch_starts.first() {
668                    let slot = std::sync::Arc::get_mut(&mut transfers[0])
669                        .ok_or_else(|| resource_limit("transfer slot is still in use"))?;
670                    fill_staging(
671                        kernel,
672                        std::sync::Arc::get_mut(&mut staging[0])
673                            .ok_or_else(|| resource_limit("staging area is still in use"))?,
674                        &mut slot.buffer,
675                        provider,
676                        first_start,
677                        stripe_offset,
678                        actual_len,
679                        aligned_len,
680                        contract,
681                        &mut slice_lens,
682                    )?;
683                    if let Some(observer) = observer.as_mut() {
684                        observe_batch(
685                            &mut **observer,
686                            transfers[0].buffer.as_bytes(),
687                            first_start,
688                            live_batch_inputs(source_count, first_start, contract),
689                            transfer_slot_stride(aligned_len)?,
690                            &slice_lens,
691                        )?;
692                    }
693                }
694                for (batch_index, &source_start) in batch_starts.iter().enumerate() {
695                    check_cancel(options)?;
696                    let live_inputs = live_batch_inputs(source_count, source_start, contract);
697                    let next_start = batch_starts.get(batch_index + 1).copied();
698                    let current_area = batch_index % staging_areas;
699                    let next_area = (batch_index + 1) % staging_areas;
700                    accumulate_batch(
701                        kernel,
702                        &mut output.as_bytes_mut()[..buffers.output_bytes],
703                        &staging[current_area],
704                        &factors,
705                        &self.recovery_exponents,
706                        source_start,
707                        live_inputs,
708                        aligned_len,
709                        buffers.row_stride,
710                        contract,
711                        band_size,
712                        #[cfg(target_arch = "x86_64")]
713                        &mut jit_workspaces,
714                        #[cfg(target_arch = "x86_64")]
715                        jit_code_budget,
716                    )?;
717                    if let Some(next_start) = next_start {
718                        let slot = std::sync::Arc::get_mut(&mut transfers[next_area])
719                            .ok_or_else(|| resource_limit("transfer slot is still in use"))?;
720                        fill_staging(
721                            kernel,
722                            std::sync::Arc::get_mut(&mut staging[next_area])
723                                .ok_or_else(|| resource_limit("staging area is still in use"))?,
724                            &mut slot.buffer,
725                            provider,
726                            next_start,
727                            stripe_offset,
728                            actual_len,
729                            aligned_len,
730                            contract,
731                            &mut slice_lens,
732                        )?;
733                        if let Some(observer) = observer.as_mut() {
734                            observe_batch(
735                                &mut **observer,
736                                transfers[next_area].buffer.as_bytes(),
737                                next_start,
738                                live_batch_inputs(source_count, next_start, contract),
739                                transfer_slot_stride(aligned_len)?,
740                                &slice_lens,
741                            )?;
742                        }
743                    }
744                }
745
746                finish_output(
747                    kernel,
748                    &mut output.as_bytes_mut()[..buffers.output_bytes],
749                    buffers.row_stride,
750                    aligned_len,
751                    self.recovery_exponents.len(),
752                )?;
753            }
754
755            for (output_index, &exponent) in self.recovery_exponents.iter().enumerate() {
756                let start = output_index
757                    .checked_mul(buffers.row_stride)
758                    .ok_or_else(|| resource_limit("output stripe offset overflow"))?;
759                let end = start
760                    .checked_add(actual_len)
761                    .ok_or_else(|| resource_limit("output stripe end overflow"))?;
762                sink.write_recovery_chunk(
763                    output_index,
764                    exponent,
765                    stripe_offset as u64,
766                    &output.as_bytes()[start..end],
767                )?;
768            }
769
770            stripe_index += 1;
771            let completed_stripe = u32::try_from(stripe_index - 1)
772                .map_err(|_| resource_limit("completed stripe exceeds progress range"))?;
773            report_progress(
774                options,
775                completed_stripe,
776                stripe_count_u32,
777                (stripe_index as u64)
778                    .saturating_mul(self.recovery_exponents.len() as u64)
779                    .saturating_mul(buffers.chunk_len as u64)
780                    .min(total_bytes),
781                total_bytes,
782            );
783            stripe_offset = stripe_offset
784                .checked_add(actual_len)
785                .ok_or_else(|| resource_limit("stripe offset overflow"))?;
786        }
787
788        check_cancel(options)?;
789        Ok(EncodeAttempt::Complete)
790    }
791}
792
793/// The per-stripe quantities every band worker and the producer share.
794#[derive(Clone, Copy)]
795struct StripeGeometry {
796    stripe_offset: usize,
797    actual_len: usize,
798    aligned_len: usize,
799    output_stride: usize,
800}
801
802/// One input batch's raw source bytes, one 64-byte-aligned slot per source,
803/// with everything the source hasher needs to read them back.
804///
805/// Held in the same ring the staged areas are, and handed to the bands on the
806/// same [`BatchTicket`], so the batch a band accumulates and the batch it may
807/// hash are one object with one lifetime.
808struct TransferSlot {
809    buffer: AlignedBuffer,
810    /// Distance between consecutive raw source slots in `buffer`.
811    slot_stride: usize,
812    /// Unpadded length of each source's slice in this stripe.
813    slice_lens: [usize; MAX_INPUT_GROUPING],
814}
815
816impl TransferSlot {
817    fn new(bytes: usize) -> Self {
818        Self {
819            buffer: AlignedBuffer::new(bytes),
820            slot_stride: 0,
821            slice_lens: [0; MAX_INPUT_GROUPING],
822        }
823    }
824}
825
826/// One filled staging area handed from the producer to the band workers.
827///
828/// The `Arc` is the hand-off: the producer cannot refill an area until every
829/// band has dropped its clone, which is exactly the condition
830/// [`StripeFeed`] tracks, and `Arc::get_mut` then proves it rather than
831/// trusting it. The raw transfer slot rides the same ticket, so the same
832/// proof covers the bytes the source hasher still has to read.
833#[derive(Clone)]
834struct BatchTicket {
835    staging: std::sync::Arc<AlignedBuffer>,
836    transfer: std::sync::Arc<TransferSlot>,
837    source_start: usize,
838    live_inputs: usize,
839}
840
841struct FeedState {
842    tickets: Vec<Option<BatchTicket>>,
843    /// Batches published so far; a band may consume batch `index` once
844    /// `published > index`.
845    published: usize,
846    /// Batches every band has finished; the producer may refill the area of
847    /// batch `index` once `completed + areas > index`.
848    completed: usize,
849    /// Bands that have finished the batch currently resident in each area.
850    /// Unambiguous because a band can never be more than one batch ahead of
851    /// the slowest: reaching batch `b + 2` needs `published > b + 2`, which
852    /// needs `completed > b`, which needs every band to have finished `b`.
853    done: Vec<usize>,
854    /// The next batch whose source bytes may be hashed. The whole-file MD5 is
855    /// one serial message per file, so the observer must see the batches in
856    /// index order however they are shared out (see
857    /// [`accumulate_band_stream`]).
858    hash_turn: usize,
859    /// Set by whichever side failed first (producer error, cancellation, or a
860    /// band's error) so the other side stops waiting instead of deadlocking.
861    failed: bool,
862}
863
864/// The bounded producer/consumer hand-off for one stripe.
865struct StripeFeed {
866    areas: usize,
867    state: std::sync::Mutex<FeedState>,
868    ready: std::sync::Condvar,
869    free: std::sync::Condvar,
870    hashed: std::sync::Condvar,
871    band_count: usize,
872}
873
874impl StripeFeed {
875    fn new(band_count: usize, areas: usize) -> Self {
876        Self {
877            areas,
878            state: std::sync::Mutex::new(FeedState {
879                tickets: vec![None; areas],
880                published: 0,
881                completed: 0,
882                done: vec![0; areas],
883                hash_turn: 0,
884                failed: false,
885            }),
886            ready: std::sync::Condvar::new(),
887            free: std::sync::Condvar::new(),
888            hashed: std::sync::Condvar::new(),
889            band_count,
890        }
891    }
892
893    fn lock(&self) -> std::sync::MutexGuard<'_, FeedState> {
894        self.state
895            .lock()
896            .unwrap_or_else(std::sync::PoisonError::into_inner)
897    }
898
899    /// Producer: block until the area for `batch_index` may be refilled, and
900    /// release the producer-side ticket clone that pins it. `false` means the
901    /// pass has already failed and the producer must stop.
902    fn wait_for_area(&self, batch_index: usize) -> bool {
903        let mut state = self.lock();
904        while !state.failed && state.completed + self.areas <= batch_index {
905            state = self
906                .free
907                .wait(state)
908                .unwrap_or_else(std::sync::PoisonError::into_inner);
909        }
910        if state.failed {
911            return false;
912        }
913        state.tickets[batch_index % self.areas] = None;
914        true
915    }
916
917    /// Producer: hand a filled area to the bands.
918    fn publish(&self, batch_index: usize, ticket: BatchTicket) {
919        let mut state = self.lock();
920        state.tickets[batch_index % self.areas] = Some(ticket);
921        state.published = batch_index + 1;
922        drop(state);
923        self.ready.notify_all();
924    }
925
926    /// Band: block until batch `batch_index` is available. `None` means the
927    /// pass failed elsewhere and this band must stop.
928    fn acquire(&self, batch_index: usize) -> Option<BatchTicket> {
929        let mut state = self.lock();
930        while !state.failed && state.published <= batch_index {
931            state = self
932                .ready
933                .wait(state)
934                .unwrap_or_else(std::sync::PoisonError::into_inner);
935        }
936        if state.failed {
937            return None;
938        }
939        state.tickets[batch_index % self.areas].clone()
940    }
941
942    /// Band: record that this band is done with `batch_index`. Must be called
943    /// only after the band's own ticket clone has been dropped.
944    fn release(&self, batch_index: usize) {
945        let mut state = self.lock();
946        let area = batch_index % self.areas;
947        state.done[area] += 1;
948        if state.done[area] == self.band_count {
949            state.done[area] = 0;
950            state.completed = batch_index + 1;
951            drop(state);
952            self.free.notify_all();
953        }
954    }
955
956    /// Band: block until this band's turn to hash batch `batch_index` comes
957    /// round. `false` means the pass failed elsewhere and this band must stop.
958    fn wait_for_hash_turn(&self, batch_index: usize) -> bool {
959        let mut state = self.lock();
960        while !state.failed && state.hash_turn < batch_index {
961            state = self
962                .hashed
963                .wait(state)
964                .unwrap_or_else(std::sync::PoisonError::into_inner);
965        }
966        !state.failed
967    }
968
969    /// Band: hand the hashing turn to the band that owns the next batch. Must
970    /// be called only after this band's own `observe` call has returned.
971    fn finish_hash_turn(&self, batch_index: usize) {
972        let mut state = self.lock();
973        state.hash_turn = batch_index + 1;
974        drop(state);
975        self.hashed.notify_all();
976    }
977
978    /// Stop every side. Idempotent, and safe to call from any of them.
979    fn fail(&self) {
980        let mut state = self.lock();
981        state.failed = true;
982        // Dropping the parked tickets here would race a band that still holds
983        // its clone; the areas are reclaimed when the whole feed is dropped.
984        drop(state);
985        self.ready.notify_all();
986        self.free.notify_all();
987        self.hashed.notify_all();
988    }
989}
990
991/// Accumulate one stripe with the band workers dispatched once, fed by this
992/// thread through [`StripeFeed`].
993///
994/// The workers are plain scoped OS threads rather than rayon tasks on purpose:
995/// a band that waits for the producer, and a producer that waits for the
996/// slowest band, are blocking waits, and blocking waits inside a work-stealing
997/// pool deadlock as soon as the pool is narrower than the band count (a queued
998/// band would never run, so the ring would never drain). The band count is the
999/// process-stable [`configured_create_threads`] value the memory plan is
1000/// already built on, so this creates exactly the workers the plan admits.
1001#[allow(clippy::too_many_arguments)]
1002fn encode_stripe_banded<P: ForwardSourceProvider + ?Sized>(
1003    kernel: ResolvedKernel,
1004    provider: &mut P,
1005    options: &ForwardEncoderOptions,
1006    contract: KernelContract,
1007    factors: &FactorSource,
1008    exponents: &[RecoveryExponent],
1009    staging: &mut [std::sync::Arc<AlignedBuffer>],
1010    transfers: &mut [std::sync::Arc<TransferSlot>],
1011    output: &mut [u8],
1012    batch_starts: &[usize],
1013    geometry: StripeGeometry,
1014    band_size: usize,
1015    #[cfg(target_arch = "x86_64")]
1016    jit_workspaces: &mut [reedsolomon_rs::xor_jit::packed::PackedJitWorkspace],
1017    #[cfg(target_arch = "x86_64")] jit_code_budget: usize,
1018    observer: Option<&mut dyn ForwardSourceObserver>,
1019) -> Result<()> {
1020    debug_assert_eq!(output.len(), exponents.len() * geometry.output_stride);
1021    let band_bytes = checked_mul(
1022        band_size,
1023        geometry.output_stride,
1024        "band byte range overflow",
1025    )?;
1026    let band_count = exponents.len().div_ceil(band_size);
1027    #[cfg(target_arch = "x86_64")]
1028    debug_assert_eq!(jit_workspaces.len(), band_count);
1029    let batch_count = batch_starts.len();
1030    let feed = StripeFeed::new(band_count, configured_staging_areas());
1031    let feed = &feed;
1032    let source_count = provider.source_count();
1033
1034    // The source hashing rides the band workers rather than a thread of its
1035    // own. It could have a dedicated thread — the queue and the transfer pool
1036    // are already the right shape for one — but then the pass runs
1037    // `bands + producer + hasher` busy threads on a host that admits `bands`,
1038    // and on a 4-core part that is a 50% oversubscription: every time either
1039    // feed thread is descheduled the ring drains inside its slack and ALL the
1040    // bands stop. Measured on 4 pinned cores, eight 32 MiB sources: the
1041    // stripe-major feed alone is 1.07x over the per-batch shape and a
1042    // dedicated hasher thread gave 4.3% of that straight back, at identical
1043    // CPU time. Sharing the digest out over the bands instead adds
1044    // `hash_cost / band_count` to each band, spawns nothing, and leaves the
1045    // arithmetic width alone (which is what a narrow host cannot spare).
1046    //
1047    // The turn is what keeps it correct: a whole-file MD5 is one serial
1048    // message per file, so batch `b` must be observed after batch `b - 1`
1049    // however the work is shared out. Band `b % band_count` owns batch `b`,
1050    // takes the turn once it has accumulated that batch, and passes the turn
1051    // on before it releases the area — so a released area is also a hashed
1052    // one, and the producer's existing `Arc::get_mut` reclaim proof covers
1053    // the raw bytes too.
1054    let observer = observer.map(std::sync::Mutex::new);
1055    let observer = observer.as_ref();
1056    let mut band_results: Vec<Result<()>> = Vec::with_capacity(band_count);
1057
1058    let produced = std::thread::scope(|scope| {
1059        let mut handles = Vec::with_capacity(band_count);
1060        let bands = output
1061            .chunks_mut(band_bytes)
1062            .zip(exponents.chunks(band_size));
1063        #[cfg(target_arch = "x86_64")]
1064        let bands = bands.zip(jit_workspaces.iter_mut());
1065        for (band_index, band) in bands.enumerate() {
1066            #[cfg(target_arch = "x86_64")]
1067            let ((band_output, band_exponents), jit_workspace) = band;
1068            #[cfg(not(target_arch = "x86_64"))]
1069            let (band_output, band_exponents) = band;
1070            handles.push(scope.spawn(move || {
1071                accumulate_band_stream(
1072                    feed,
1073                    kernel,
1074                    band_output,
1075                    band_exponents,
1076                    factors,
1077                    contract,
1078                    geometry,
1079                    batch_count,
1080                    #[cfg(target_arch = "x86_64")]
1081                    jit_workspace,
1082                    #[cfg(target_arch = "x86_64")]
1083                    jit_code_budget,
1084                    BandHashDuty {
1085                        band_index,
1086                        band_count,
1087                        observer,
1088                    },
1089                )
1090            }));
1091        }
1092
1093        let produced = produce_stripe(
1094            kernel,
1095            provider,
1096            options,
1097            contract,
1098            staging,
1099            transfers,
1100            batch_starts,
1101            geometry,
1102            source_count,
1103            feed,
1104        );
1105        if produced.is_err() {
1106            feed.fail();
1107        }
1108        band_results.extend(handles.into_iter().map(|handle| {
1109            handle
1110                .join()
1111                .unwrap_or_else(|payload| std::panic::resume_unwind(payload))
1112        }));
1113        produced
1114    });
1115
1116    produced?;
1117    for result in band_results {
1118        result?;
1119    }
1120    Ok(())
1121}
1122
1123/// A band worker's share of the fused source hashing: the batches whose index
1124/// is congruent to `band_index` modulo `band_count`.
1125#[derive(Clone, Copy)]
1126struct BandHashDuty<'turn, 'observer> {
1127    band_index: usize,
1128    band_count: usize,
1129    observer: Option<&'turn std::sync::Mutex<&'observer mut dyn ForwardSourceObserver>>,
1130}
1131
1132/// The producer half of [`encode_stripe_banded`]: fill one staging area and
1133/// its raw transfer slot per input batch, in increasing source order, and hand
1134/// both to the bands.
1135#[allow(clippy::too_many_arguments)]
1136fn produce_stripe<P: ForwardSourceProvider + ?Sized>(
1137    kernel: ResolvedKernel,
1138    provider: &mut P,
1139    options: &ForwardEncoderOptions,
1140    contract: KernelContract,
1141    staging: &mut [std::sync::Arc<AlignedBuffer>],
1142    transfers: &mut [std::sync::Arc<TransferSlot>],
1143    batch_starts: &[usize],
1144    geometry: StripeGeometry,
1145    source_count: usize,
1146    feed: &StripeFeed,
1147) -> Result<()> {
1148    let slot_stride = transfer_slot_stride(geometry.aligned_len)?;
1149    for (batch_index, &source_start) in batch_starts.iter().enumerate() {
1150        check_cancel(options)?;
1151        if !feed.wait_for_area(batch_index) {
1152            // A band already failed; its error is the one that surfaces.
1153            return Ok(());
1154        }
1155        let area = batch_index % feed.areas;
1156        let staged = std::sync::Arc::get_mut(&mut staging[area])
1157            .ok_or_else(|| resource_limit("staging area is still in use"))?;
1158        let slot = std::sync::Arc::get_mut(&mut transfers[area])
1159            .ok_or_else(|| resource_limit("transfer slot is still in use"))?;
1160        slot.slot_stride = slot_stride;
1161        fill_staging(
1162            kernel,
1163            staged,
1164            &mut slot.buffer,
1165            provider,
1166            source_start,
1167            geometry.stripe_offset,
1168            geometry.actual_len,
1169            geometry.aligned_len,
1170            contract,
1171            &mut slot.slice_lens,
1172        )?;
1173        feed.publish(
1174            batch_index,
1175            BatchTicket {
1176                staging: std::sync::Arc::clone(&staging[area]),
1177                transfer: std::sync::Arc::clone(&transfers[area]),
1178                source_start,
1179                live_inputs: live_batch_inputs(source_count, source_start, contract),
1180            },
1181        );
1182    }
1183    Ok(())
1184}
1185
1186/// One band worker: zero its own output rows, accumulate every input batch of
1187/// the stripe from the feed, hash the batches this band owns, then finish its
1188/// rows.
1189#[allow(clippy::too_many_arguments)]
1190fn accumulate_band_stream(
1191    feed: &StripeFeed,
1192    kernel: ResolvedKernel,
1193    band_output: &mut [u8],
1194    band_exponents: &[RecoveryExponent],
1195    factors: &FactorSource,
1196    contract: KernelContract,
1197    geometry: StripeGeometry,
1198    batch_count: usize,
1199    #[cfg(target_arch = "x86_64")]
1200    jit_workspace: &mut reedsolomon_rs::xor_jit::packed::PackedJitWorkspace,
1201    #[cfg(target_arch = "x86_64")] jit_code_budget: usize,
1202    hash_duty: BandHashDuty<'_, '_>,
1203) -> Result<()> {
1204    // Each band zeroes exactly its own rows, and the bands partition the
1205    // output buffer, so the union is the whole-buffer clear the per-batch
1206    // shape did on the calling thread.
1207    band_output.fill(0);
1208    for batch_index in 0..batch_count {
1209        let Some(ticket) = feed.acquire(batch_index) else {
1210            return Ok(());
1211        };
1212        let accumulated = accumulate_band(
1213            kernel,
1214            band_output,
1215            &ticket.staging,
1216            factors,
1217            band_exponents,
1218            ticket.source_start,
1219            ticket.live_inputs,
1220            geometry.aligned_len,
1221            geometry.output_stride,
1222            contract,
1223            #[cfg(target_arch = "x86_64")]
1224            jit_workspace,
1225            #[cfg(target_arch = "x86_64")]
1226            jit_code_budget,
1227        );
1228        if let Err(error) = accumulated {
1229            drop(ticket);
1230            feed.fail();
1231            return Err(error);
1232        }
1233        if let Some(hashed) = hash_batch_if_owned(feed, &ticket, batch_index, hash_duty) {
1234            if let Err(error) = hashed {
1235                drop(ticket);
1236                feed.fail();
1237                return Err(error);
1238            }
1239            // Only now may the turn move on: the observer is a single serial
1240            // stream and the next batch's owner is already waiting for it.
1241            feed.finish_hash_turn(batch_index);
1242        }
1243        // Released before the completion is recorded: the producer treats the
1244        // recorded completion as proof that no band still holds the area — of
1245        // the staged bytes and of the raw ones the hashing above just read.
1246        drop(ticket);
1247        feed.release(batch_index);
1248    }
1249    finish_band_rows(
1250        kernel,
1251        band_output,
1252        geometry.output_stride,
1253        geometry.aligned_len,
1254        band_exponents.len(),
1255    )
1256    .inspect_err(|_| feed.fail())
1257}
1258
1259/// Hash one batch's raw source bytes if this band owns that batch, blocking
1260/// until the turn reaches it.
1261///
1262/// `None` means this band owes nothing for this batch (there is no observer,
1263/// or the batch belongs to another band, or the pass has already failed
1264/// elsewhere). `Some` is this band's own result, and the caller must pass the
1265/// turn on before releasing the area.
1266fn hash_batch_if_owned(
1267    feed: &StripeFeed,
1268    ticket: &BatchTicket,
1269    batch_index: usize,
1270    duty: BandHashDuty<'_, '_>,
1271) -> Option<Result<()>> {
1272    let observer = duty.observer?;
1273    if batch_index % duty.band_count != duty.band_index {
1274        return None;
1275    }
1276    if !feed.wait_for_hash_turn(batch_index) {
1277        // Some other band or the producer already failed; that error is the
1278        // one that surfaces, and this band stops without taking the turn.
1279        return None;
1280    }
1281    // The turn is the exclusion; the lock only expresses it to the compiler,
1282    // so it is never contended by a second hasher.
1283    let mut observer = observer
1284        .lock()
1285        .unwrap_or_else(std::sync::PoisonError::into_inner);
1286    Some(observe_batch(
1287        &mut **observer,
1288        ticket.transfer.buffer.as_bytes(),
1289        ticket.source_start,
1290        ticket.live_inputs,
1291        ticket.transfer.slot_stride,
1292        &ticket.transfer.slice_lens,
1293    ))
1294}
1295
1296#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1297enum ResolvedKernel {
1298    Portable,
1299    Simd,
1300    #[cfg(target_arch = "x86_64")]
1301    Folded,
1302    #[cfg(target_arch = "x86_64")]
1303    XorJitAvx2,
1304}
1305
1306#[cfg(test)]
1307fn public_kernel(kernel: ResolvedKernel) -> ForwardKernel {
1308    match kernel {
1309        ResolvedKernel::Portable => ForwardKernel::Portable,
1310        ResolvedKernel::Simd => ForwardKernel::Simd,
1311        #[cfg(target_arch = "x86_64")]
1312        ResolvedKernel::Folded => ForwardKernel::Folded,
1313        #[cfg(target_arch = "x86_64")]
1314        ResolvedKernel::XorJitAvx2 => ForwardKernel::XorJitAvx2,
1315    }
1316}
1317
1318fn resolve_kernel_with_capabilities(
1319    requested: ForwardKernel,
1320    capabilities: KernelCapabilities,
1321) -> Result<ResolvedKernel> {
1322    #[cfg(not(target_arch = "x86_64"))]
1323    let _ = capabilities;
1324
1325    match requested {
1326        ForwardKernel::Portable => Ok(ResolvedKernel::Portable),
1327        ForwardKernel::Simd => Ok(ResolvedKernel::Simd),
1328        #[cfg(target_arch = "x86_64")]
1329        ForwardKernel::Folded => {
1330            if capabilities.folded {
1331                return Ok(ResolvedKernel::Folded);
1332            }
1333            Err(unavailable_kernel("folded AVX2"))
1334        }
1335        #[cfg(target_arch = "x86_64")]
1336        ForwardKernel::XorJitAvx2 => {
1337            if capabilities.avx2_jit {
1338                return Ok(ResolvedKernel::XorJitAvx2);
1339            }
1340            Err(unavailable_kernel("packed AVX2 XOR-JIT"))
1341        }
1342        ForwardKernel::Auto => {
1343            // The oracle's ladder, arm for arm (`default_method`,
1344            // gf16mul.cpp:1550-1572) — affine when GFNI exists, 512-bit
1345            // shuffle when AVX512BW/VL exists, 256-bit shuffle otherwise —
1346            // with one measured departure at the AVX2 line: the oracle puts
1347            // its XOR-JIT there behind the fast-JIT CPU gate, but for CREATE
1348            // our split-layout 256-bit shuffle beats our packed XOR-JIT on
1349            // that exact host class (Zen 2, 3 interleaved reps per cell):
1350            // 1.32x at 64 KiB slices, 2.85x at 16 KiB, 4.4x at 8 KiB. The JIT
1351            // builds one multi-row batch per input batch, and that build is
1352            // the whole gap once slices shrink; the shuffle builds nothing.
1353            // So the folded family (GFNI affine, 512-bit shuffle, or 256-bit
1354            // shuffle by capability) is the automatic choice wherever it
1355            // exists, and the packed XOR-JIT stays an explicit request
1356            // (`WEAVER_PAR2_CREATE_KERNEL=xor-jit-avx2`) so it can be A/B'd
1357            // any time. This is create only: the repair side keeps its own
1358            // AVX2 codebook behind the same gate, where it is measured to
1359            // win. The AVX-512 JIT is gone entirely (c5-measured; git
1360            // history preserves it).
1361            #[cfg(target_arch = "x86_64")]
1362            {
1363                if capabilities.folded {
1364                    return Ok(ResolvedKernel::Folded);
1365                }
1366                if capabilities.avx2_jit {
1367                    return Ok(ResolvedKernel::XorJitAvx2);
1368                }
1369            }
1370            Ok(ResolvedKernel::Simd)
1371        }
1372    }
1373}
1374
1375#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1376struct KernelCapabilities {
1377    /// Split-layout folded family available (AVX2 present).
1378    folded: bool,
1379    /// The folded family's non-GFNI arm runs the 512-bit shuffle kernel.
1380    folded_wide: bool,
1381    /// Packed AVX2 XOR-JIT usable: fast-JIT CPU, no GFNI, strict W^X, not
1382    /// binary-translated (`JitWidth::detect`).
1383    avx2_jit: bool,
1384}
1385
1386fn runtime_kernel_capabilities() -> KernelCapabilities {
1387    #[cfg(target_arch = "x86_64")]
1388    {
1389        KernelCapabilities {
1390            folded: gf_simd::altmap_supported(),
1391            folded_wide: gf_simd::folded_wide_shuffle_available(),
1392            avx2_jit: reedsolomon_rs::xor_jit::JitWidth::detect().is_some(),
1393        }
1394    }
1395    #[cfg(not(target_arch = "x86_64"))]
1396    KernelCapabilities {
1397        folded: false,
1398        folded_wide: false,
1399        avx2_jit: false,
1400    }
1401}
1402
1403/// Bytes of one input region that a band's output rows consume together.
1404///
1405/// The stripe length handed to [`accumulate_band`] comes from [`BufferPlan`],
1406/// which takes the largest chunk the memory budget allows — so without an inner
1407/// tile every output row of the band re-streams the whole
1408/// `input_grouping * aligned_len` staging area from memory, and the reuse
1409/// distance is a memory-budget number rather than a cache-sized one. Tiling the
1410/// byte dimension *inside* the in-memory stripe fixes that reuse distance
1411/// without touching the stripe: sources are still read once per stripe and the
1412/// coefficient state is still built once per (batch, band).
1413///
1414/// The constants are per kernel FAMILY, mirroring the reference's per-method
1415/// ideal chunk size (4 KiB where the multiply is a GFNI affine transform,
1416/// 8 KiB where it is a table/shuffle or CLMUL body): a family is a
1417/// kernel-availability fact, exactly like the tier ladder itself. They are
1418/// deliberately not per-microarchitecture and carry no topology probe.
1419/// Only the folded family selects this tile, and that family is x86-only.
1420#[cfg_attr(not(target_arch = "x86_64"), allow(dead_code))]
1421const AFFINE_TILE_BYTES: usize = 4 * 1024;
1422const TABLE_TILE_BYTES: usize = 8 * 1024;
1423/// Sentinel for a family that consumes the whole stripe in one call. Only the
1424/// packed XOR-JIT family selects it, and that family is x86-only.
1425#[cfg_attr(not(target_arch = "x86_64"), allow(dead_code))]
1426const UNTILED: usize = usize::MAX;
1427
1428/// A/B override for the per-family tile, in bytes; `0` selects the untiled
1429/// shape. Same escape-hatch pattern as `WEAVER_PAR2_CREATE_THREADS`: it exists
1430/// so the tiled and untiled shapes can be compared, and the ladder's constants
1431/// re-derived on new hardware, without a rebuild. Nothing in the plan depends
1432/// on it — the tile lives strictly inside one already-planned stripe, so every
1433/// `Par2MemoryPlan` and `ForwardMemoryEstimate` number is identical at every
1434/// setting, as are the produced recovery bytes.
1435///
1436/// Process-stable by construction, for the same reason the band count is: two
1437/// reads inside one pass must not disagree.
1438fn configured_tile_bytes() -> Option<usize> {
1439    static CONFIGURED: std::sync::OnceLock<Option<usize>> = std::sync::OnceLock::new();
1440    *CONFIGURED.get_or_init(|| {
1441        std::env::var("WEAVER_PAR2_CREATE_TILE")
1442            .ok()
1443            .and_then(|value| value.trim().parse::<usize>().ok())
1444            .map(|bytes| if bytes == 0 { UNTILED } else { bytes })
1445    })
1446}
1447
1448/// Resolve one family's tile: the A/B override when set, otherwise the
1449/// family's constant, rounded up to a whole number of kernel strides.
1450fn family_tile_bytes(default_bytes: usize, stride: usize) -> usize {
1451    let requested = configured_tile_bytes().unwrap_or(default_bytes);
1452    if requested == UNTILED || stride == 0 {
1453        return requested;
1454    }
1455    requested
1456        .max(stride)
1457        .div_ceil(stride)
1458        .saturating_mul(stride)
1459}
1460
1461/// Largest skew inserted between consecutive staging lanes and between
1462/// consecutive output rows, in bytes.
1463///
1464/// A stripe of `aligned_len` bytes per lane used to place input lane `l` at
1465/// `l * aligned_len` and output row `r` at `r * aligned_len`. For the
1466/// power-of-two stripes real jobs run (64 KiB slices), every lane and the row a
1467/// kernel pass reads at one offset then map to the *same* L1D set: the CLMUL
1468/// arm's 8-source pass plus its destination is 9 lines competing for a 4-way
1469/// (Neoverse N1/V2) or 2-way (Cortex-A72) set, and every block refills. The
1470/// fleet's own counters showed it — 35 L1D refills per thousand instructions
1471/// against the reference's 1.6–2.5 on the same create at near-equal
1472/// instruction counts (fullround-20260815T215405Z, v2/n1) — and a code-free
1473/// A/B reproduced the mechanism on x86 (Alder Lake `simd` arm: 8.47% → 4.45%
1474/// L1D misses, cycles −4.2%, when the slice moved from 65,536 to 66,560 bytes
1475/// and nothing else changed). The split-layout folded family interleaves six
1476/// lanes per stream and was flat in the same A/B, which is the control.
1477///
1478/// The skew makes the lane and row stride land at `1 KiB (mod 4 KiB)`. Every
1479/// stride that is a multiple of 4 KiB puts consecutive lanes in the same set
1480/// group of every common L1D (4 KiB, 8 KiB and 16 KiB way sizes), and a
1481/// 2 KiB residue only halves that; a 1 KiB residue gives four lane groups on a
1482/// 4 KiB way size and, because 5 is coprime to 16, twelve distinct 16-set
1483/// windows on a 16 KiB way size — with room for the prefetch window in both.
1484/// The same x86 A/B measured all four residues: 0 → 8.47% misses, 2 KiB →
1485/// 6.85%, 1 KiB → 4.4% (twice, from either side). The skew is capped at 1/8 of
1486/// the stripe so short stripes never pay more than 12.5% extra memory, and a
1487/// stripe whose stride already has the residue pays none. This is a fixed rule
1488/// of the stripe length — no cache probe, no topology input — and it changes
1489/// no arithmetic: only where bytes sit.
1490const SKEW_PERIOD_BYTES: usize = 4096;
1491const SKEW_TARGET_RESIDUE_BYTES: usize = 1024;
1492
1493/// Bytes of skew between consecutive lanes/rows of a stripe of `aligned_len`
1494/// bytes: the smallest amount that moves the stride to
1495/// [`SKEW_TARGET_RESIDUE_BYTES`] modulo [`SKEW_PERIOD_BYTES`], capped at
1496/// `aligned_len / 8` and rounded down to whole 64-byte lines so every lane and
1497/// row start keeps the alignment the stripe itself has.
1498fn stripe_skew_bytes(aligned_len: usize) -> usize {
1499    let residue = aligned_len % SKEW_PERIOD_BYTES;
1500    let wanted = (SKEW_TARGET_RESIDUE_BYTES + SKEW_PERIOD_BYTES - residue) % SKEW_PERIOD_BYTES;
1501    let cap = aligned_len / 8;
1502    wanted.min(cap) / 64 * 64
1503}
1504
1505/// Distance between consecutive staging lanes for one stripe: skewed for the
1506/// families whose kernels take one slice per source, and exactly `aligned_len`
1507/// for the packed XOR-JIT family, whose `PackedRun` addresses source region
1508/// `r` at `src + r * len` by contract.
1509fn lane_stride(contract: KernelContract, aligned_len: usize) -> usize {
1510    if contract.skewed_lanes {
1511        aligned_len + stripe_skew_bytes(aligned_len)
1512    } else {
1513        aligned_len
1514    }
1515}
1516
1517/// Where one input batch's staging bytes live.
1518///
1519/// Lanes are taken `interleave` at a time and each group's lanes are
1520/// **block-interleaved** into one contiguous stream: lane `j` of group `g`
1521/// starts its block `b` at
1522/// `group_base(g) + (b * width(g) + j) * INPUT_BATCH_BLOCK_BYTES`. A kernel
1523/// pass over the group therefore walks that stream front to back once —
1524/// **one** source stream plus the destination — where a lane-major layout gives
1525/// it `width` sources plus the destination at a shared offset, i.e. `width + 1`
1526/// lines wanting one L1D set per block. That is the whole point: contiguity is
1527/// associativity-independent, where the [`SKEW_PERIOD_BYTES`] residue rule only
1528/// moves the collision around and needs `width + 1` ways to pay off (it did on
1529/// the 4-way Neoverse parts and did not on the 2-way Cortex-A72).
1530///
1531/// `interleave == 1` is the lane-major layout and reproduces the pre-interleave
1532/// addresses exactly: `group_base(l) = l * lane_stride`, one lane per group.
1533/// Every family except `Simd` uses it.
1534///
1535/// Never larger than the planned staging area: the interleaved total is
1536/// `input_grouping * aligned_len + (groups - 1) * skew`, and the plan reserves
1537/// `input_grouping * (aligned_len + skew)`, which is larger for every
1538/// `groups <= input_grouping`.
1539#[derive(Clone, Copy)]
1540struct StagingLayout {
1541    /// Lanes per interleaved group; `1` = lane-major.
1542    interleave: usize,
1543    /// Lanes in the batch (the family's input grouping).
1544    lanes: usize,
1545    /// Payload bytes per lane in this stripe.
1546    aligned_len: usize,
1547    /// Distance between consecutive group bases.
1548    group_pitch: usize,
1549}
1550
1551impl StagingLayout {
1552    fn new(contract: KernelContract, aligned_len: usize, lane_stride: usize) -> Self {
1553        let lanes = contract.input_grouping.max(1);
1554        let interleave = contract.interleave_lanes.clamp(1, lanes);
1555        let group_pitch = if interleave == 1 {
1556            lane_stride
1557        } else {
1558            // One group is `interleave` lanes wide; keep the skew rule between
1559            // groups, which are still separate streams even though the lanes
1560            // inside one no longer are.
1561            interleave * aligned_len + stripe_skew_bytes(aligned_len)
1562        };
1563        Self {
1564            interleave,
1565            lanes,
1566            aligned_len,
1567            group_pitch,
1568        }
1569    }
1570
1571    fn group_count(&self) -> usize {
1572        self.lanes.div_ceil(self.interleave).max(1)
1573    }
1574
1575    /// Lanes actually in `group` — the last group is short when the grouping is
1576    /// not a multiple of the interleave (twelve inputs interleaved eight-wide
1577    /// is a group of eight and a group of four), and its stream is narrower to
1578    /// match, so the layout never claims bytes the plan did not reserve.
1579    fn group_width(&self, group: usize) -> usize {
1580        self.lanes
1581            .saturating_sub(group * self.interleave)
1582            .min(self.interleave)
1583    }
1584
1585    fn group_base(&self, group: usize) -> usize {
1586        group * self.group_pitch
1587    }
1588
1589    /// Bytes this layout occupies, or `None` on overflow.
1590    fn total_bytes(&self) -> Option<usize> {
1591        let last = self.group_count() - 1;
1592        last.checked_mul(self.group_pitch)?
1593            .checked_add(self.group_width(last).checked_mul(self.aligned_len)?)
1594    }
1595
1596    /// Byte range of `group`'s stream covering the tile at `tile_start`.
1597    ///
1598    /// The group's stream holds `width` bytes for every logical byte of a lane,
1599    /// so a tile of the lanes is the same tile of the stream, scaled.
1600    fn group_tile(&self, group: usize, tile_start: usize, tile_len: usize) -> (usize, usize) {
1601        let width = self.group_width(group);
1602        let start = self.group_base(group) + tile_start * width;
1603        (start, start + tile_len * width)
1604    }
1605}
1606
1607/// Output rows whose coefficient state is built in one step.
1608///
1609/// The tile loop runs *inside* this, which is what keeps a row's coefficients
1610/// built once per (input batch, row) rather than once per tile: tiling must
1611/// not turn into a coefficient rebuild multiplier. Holding whole bands instead
1612/// would make the workspace scale with the recovery-row count, so this is a
1613/// compile-time constant — the per-band temporaries then stay a fixed size,
1614/// scaling with neither recovery rows nor threads, which is what
1615/// [`factor_workspace_bytes`] promises.
1616const COEFF_ROWS: usize = 16;
1617
1618/// Byte ranges of one stripe in `tile_bytes` steps, last range short.
1619///
1620/// `aligned_len` is a multiple of the kernel stride and every tile constant is
1621/// a multiple of every stride in the ladder, so every emitted range is
1622/// stride-aligned — which is what lets the split-layout and word-wise kernels
1623/// be invoked per tile at all.
1624fn stripe_tiles(aligned_len: usize, tile_bytes: usize) -> impl Iterator<Item = (usize, usize)> {
1625    let tile = tile_bytes.min(aligned_len).max(1);
1626    (0..aligned_len)
1627        .step_by(tile)
1628        .map(move |start| (start, tile.min(aligned_len - start)))
1629}
1630
1631#[derive(Clone, Copy)]
1632struct KernelContract {
1633    stride: usize,
1634    input_grouping: usize,
1635    tile_bytes: usize,
1636    /// Whether staging lanes sit `lane_stride` apart (skewed) rather than
1637    /// exactly `aligned_len` apart. See [`SKEW_PERIOD_BYTES`].
1638    skewed_lanes: bool,
1639    /// Source lanes block-interleaved into one contiguous staging stream;
1640    /// `1` is the lane-major layout. See [`StagingLayout`] and
1641    /// [`configured_interleave_lanes`].
1642    interleave_lanes: usize,
1643}
1644
1645impl KernelContract {
1646    fn for_kernel(kernel: ResolvedKernel) -> Self {
1647        match kernel {
1648            // The word-wise reference walks one source at a time, so it wants
1649            // lanes it can address with a plain stride and a granularity of one
1650            // GF word — the layout this family has always had.
1651            ResolvedKernel::Portable => Self {
1652                stride: 2,
1653                input_grouping: configured_input_grouping(),
1654                tile_bytes: family_tile_bytes(TABLE_TILE_BYTES, 2),
1655                skewed_lanes: true,
1656                interleave_lanes: 1,
1657            },
1658            ResolvedKernel::Simd => Self {
1659                stride: simd_stride(),
1660                input_grouping: configured_input_grouping(),
1661                tile_bytes: family_tile_bytes(TABLE_TILE_BYTES, simd_stride()),
1662                // With an interleave the skew separates whole groups rather
1663                // than single lanes; one rule, either way.
1664                skewed_lanes: true,
1665                interleave_lanes: configured_interleave_lanes(),
1666            },
1667            #[cfg(target_arch = "x86_64")]
1668            ResolvedKernel::Folded => Self {
1669                stride: gf_simd::SPLIT_BLOCK_BYTES,
1670                input_grouping: DEFAULT_INPUT_GROUPING,
1671                // The folded arm dispatches to the affine kernel exactly when
1672                // GFNI is usable and to the shuffle tables otherwise; that is
1673                // the same availability answer the arm itself branches on, so
1674                // the tile follows the kernel that will actually run.
1675                tile_bytes: family_tile_bytes(
1676                    if gf_simd::folded_uses_gfni() {
1677                        AFFINE_TILE_BYTES
1678                    } else {
1679                        TABLE_TILE_BYTES
1680                    },
1681                    gf_simd::SPLIT_BLOCK_BYTES,
1682                ),
1683                // Six lanes share one interleaved stream here, so the skew
1684                // separates the two group streams; harmless, and it keeps one
1685                // layout rule for every slice-per-source family.
1686                skewed_lanes: true,
1687                // This family does its own six-lane interleave inside
1688                // `split_encode_scatter`, which also splits the byte planes;
1689                // the generic block interleave is not its layout.
1690                interleave_lanes: 1,
1691            },
1692            #[cfg(target_arch = "x86_64")]
1693            ResolvedKernel::XorJitAvx2 => Self {
1694                stride: reedsolomon_rs::xor_jit::JitWidth::Avx2.block_bytes(),
1695                input_grouping: DEFAULT_INPUT_GROUPING,
1696                // Untiled by family contract: `PackedRun` addresses source
1697                // region `r` at `src + r * len`, so a sub-range of the stripe
1698                // is not expressible without re-laying-out staging.
1699                tile_bytes: UNTILED,
1700                // The same contract fixes the lane stride at `len`; the skew
1701                // for this family needs a `PackedRun` source stride first.
1702                skewed_lanes: false,
1703                // `PackedRun` addresses region `r` at `src + r * len`, which is
1704                // lane-major by contract.
1705                interleave_lanes: 1,
1706            },
1707        }
1708    }
1709}
1710
1711fn factor_workspace_bytes(kernel: ResolvedKernel, source_count: usize) -> Result<usize> {
1712    let constants = checked_mul(
1713        source_count,
1714        size_of::<u16>(),
1715        "factor constant allocation overflow",
1716    )?;
1717    // The per-row arrays are sized for the widest grouping; the per-chunk
1718    // vectors below follow the family's actual grouping.
1719    let grouping = KernelContract::for_kernel(kernel).input_grouping;
1720    let row = checked_mul(
1721        MAX_INPUT_GROUPING,
1722        size_of::<u16>(),
1723        "factor row allocation overflow",
1724    )?;
1725    let active = match kernel {
1726        ResolvedKernel::Portable => row,
1727        ResolvedKernel::Simd => checked_add(
1728            row,
1729            checked_add(
1730                checked_mul(
1731                    // One row chunk's prepared factors, not one row's: the tile
1732                    // loop runs inside a chunk of `COEFF_ROWS` rows so no row's
1733                    // coefficients are rebuilt per tile. A compile-time count,
1734                    // so this still scales with neither rows nor threads.
1735                    checked_mul(COEFF_ROWS, grouping, "prepared factor allocation overflow")?,
1736                    size_of::<gf_simd::PreparedInputFactor>(),
1737                    "prepared factor allocation overflow",
1738                )?,
1739                checked_mul(
1740                    MAX_INPUT_GROUPING,
1741                    size_of::<PreparedFactorSrc>(),
1742                    "prepared source allocation overflow",
1743                )?,
1744                "prepared factor allocation overflow",
1745            )?,
1746            "prepared factor allocation overflow",
1747        )?,
1748        #[cfg(target_arch = "x86_64")]
1749        ResolvedKernel::Folded => {
1750            let groups = DEFAULT_INPUT_GROUPING / gf_simd::FOLDED_GROUP;
1751            // One row chunk's tables, not one row's; see the SIMD arm above.
1752            let chunk_lanes = checked_mul(
1753                COEFF_ROWS,
1754                DEFAULT_INPUT_GROUPING,
1755                "folded table allocation overflow",
1756            )?;
1757            let affine_tables = checked_mul(
1758                chunk_lanes,
1759                size_of::<gf_simd::AffineMulMatrices>(),
1760                "folded affine table allocation overflow",
1761            )?;
1762            let shuffle_tables = checked_mul(
1763                chunk_lanes,
1764                size_of::<gf_simd::Shuffle2xTables>(),
1765                "folded shuffle table allocation overflow",
1766            )?;
1767            let staging_views = checked_mul(
1768                groups,
1769                size_of::<&[u8]>(),
1770                "folded staging view allocation overflow",
1771            )?;
1772            let affine_sets = checked_mul(
1773                groups,
1774                size_of::<[&gf_simd::AffineMulMatrices; gf_simd::FOLDED_GROUP]>(),
1775                "folded affine set allocation overflow",
1776            )?;
1777            let shuffle_sets = checked_mul(
1778                groups,
1779                size_of::<[&gf_simd::Shuffle2xTables; gf_simd::FOLDED_GROUP]>(),
1780                "folded shuffle set allocation overflow",
1781            )?;
1782            checked_add(
1783                row,
1784                [
1785                    affine_tables,
1786                    shuffle_tables,
1787                    staging_views,
1788                    affine_sets,
1789                    shuffle_sets,
1790                ]
1791                .into_iter()
1792                .try_fold(0usize, |total, bytes| {
1793                    checked_add(total, bytes, "folded factor allocation overflow")
1794                })?,
1795                "folded factor allocation overflow",
1796            )?
1797        }
1798        #[cfg(target_arch = "x86_64")]
1799        ResolvedKernel::XorJitAvx2 => row,
1800    };
1801    // This counts ONE band's coefficient storage. The other bands' copies are
1802    // deliberately excluded: this value feeds
1803    // Par2MemoryPlan.factor_workspace_bytes, which must not scale with
1804    // recovery-row or band count, and every term above is a compile-time
1805    // quantity for exactly that reason.
1806    checked_add(constants, active, "factor workspace allocation overflow")
1807}
1808
1809/// Reserved bytes for the banded JIT workspaces, and the per-build arena
1810/// limit handed to them. Each band holds ONE active multi-row batch at a
1811/// time (all of the band's rows for the current input batch) and recycles it
1812/// before the next batch, so the reservation is one band-sized arena per
1813/// band and never scales with the input-batch count.
1814fn jit_workspace_bytes(kernel: ResolvedKernel, output_count: usize) -> Result<(usize, usize)> {
1815    #[cfg(target_arch = "x86_64")]
1816    if matches!(kernel, ResolvedKernel::XorJitAvx2) {
1817        let (band_size, band_count) = create_band_shape(output_count.max(1));
1818        let estimate = reedsolomon_rs::xor_jit::packed::PackedJitBatch::memory_upper_bound(
1819            reedsolomon_rs::xor_jit::JitWidth::Avx2,
1820            band_size.max(1),
1821            DEFAULT_INPUT_GROUPING,
1822        )
1823        .ok_or_else(|| resource_limit("packed JIT workspace size overflows"))?;
1824        let reserved = estimate
1825            .peak_bytes
1826            .checked_mul(band_count)
1827            .ok_or_else(|| resource_limit("banded JIT workspace accounting overflows"))?;
1828        return Ok((reserved, estimate.executable_arena_bytes));
1829    }
1830    let _ = (kernel, output_count);
1831    Ok((0, 0))
1832}
1833
1834/// Optional cache-oriented cap on one stripe's working set, in MiB, from
1835/// `WEAVER_PAR2_CREATE_STRIPE_MIB`. Unset or `0` keeps the shipped behavior:
1836/// [`BufferPlan`] takes the largest chunk the caller's memory budget allows.
1837///
1838/// Why the hatch exists, and why it is not the default. The recovery-output
1839/// stripe (`output_count * aligned_chunk_len`) is read and written once per
1840/// *input batch*, so a stripe larger than the last-level cache makes every
1841/// batch re-stream all of it from memory; capping the stripe is what decouples
1842/// the chunk from `physical_memory / 8`. Measured both ways, same corpus
1843/// (128 MiB over 2048 input slices, 410 recovery slices, 64 KiB slice),
1844/// shipped default vs this cap at 8 MiB:
1845///
1846/// - 12th-gen mobile x86 (12 MB L3, GFNI-folded path): 3.96 -> 3.31 CPU-s and
1847///   0.52 -> 0.42 s wall. The cache effect is real and large.
1848/// - Apple-silicon aarch64 (18 threads, NEON/CLMUL path): 3.03 -> 4.19 CPU-s.
1849///   The chunk size itself costs nothing there — with banding off
1850///   (`WEAVER_PAR2_CREATE_THREADS=1`) the two budgets are indistinguishable
1851///   (2.12 vs 2.14 user-s, 0.05 vs 0.06 sys-s). The whole regression is the
1852///   per-`(stripe, batch)` rayon dispatch, which the smaller chunk multiplies
1853///   by the stripe count.
1854///
1855/// So the win is gated behind an implementation artifact, not a hardware
1856/// property: while the parallel dispatch happens once per (stripe, batch)
1857/// rather than once per stripe, shrinking the stripe trades memory traffic for
1858/// thread wakeups, and which side wins is a property of the host's cache and
1859/// its thread-park cost. Making a smaller stripe unconditionally right needs
1860/// the staging area to hold the stripe for *all* sources so each band can walk
1861/// the batches itself; that is a separate change, and this hatch is here so
1862/// the cap can be re-measured on any host without a rebuild until then.
1863///
1864/// Process-stable, and read through the same function by both the encoder and
1865/// [`estimate_forward_memory`], so a plan and the pass it admits always agree.
1866/// Parse a `WEAVER_PAR2_CREATE_KERNEL` value. Split out from the env reader
1867/// so the mapping is unit-testable without process-global state.
1868fn parse_kernel_override(value: &str) -> Result<ForwardKernel> {
1869    match value.trim().to_ascii_lowercase().as_str() {
1870        "auto" => Ok(ForwardKernel::Auto),
1871        "portable" => Ok(ForwardKernel::Portable),
1872        "simd" => Ok(ForwardKernel::Simd),
1873        #[cfg(target_arch = "x86_64")]
1874        "folded" => Ok(ForwardKernel::Folded),
1875        #[cfg(target_arch = "x86_64")]
1876        "xor-jit-avx2" => Ok(ForwardKernel::XorJitAvx2),
1877        other => Err(invalid_input(format!(
1878            "WEAVER_PAR2_CREATE_KERNEL={other:?} names no kernel on this \
1879             architecture; use auto, portable, simd, folded or xor-jit-avx2"
1880        ))),
1881    }
1882}
1883
1884/// Optional create-kernel override from `WEAVER_PAR2_CREATE_KERNEL`, so a
1885/// tier A/B never needs a rebuild (there is no CLI flag for the kernel).
1886///
1887/// The override replaces the caller's requested kernel *before* capability
1888/// resolution, so forcing a kernel this host cannot run fails the pass loudly
1889/// through `unavailable_kernel` instead of silently measuring another tier,
1890/// and an unrecognized value is an error for the same reason. Process-stable,
1891/// and applied inside `select_kernel_for_memory`, which both the encoder and
1892/// `estimate_forward_memory` funnel through, so a plan and the pass it admits
1893/// always agree.
1894fn configured_kernel_override() -> Result<Option<ForwardKernel>> {
1895    static CONFIGURED: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
1896    CONFIGURED
1897        .get_or_init(|| std::env::var("WEAVER_PAR2_CREATE_KERNEL").ok())
1898        .as_deref()
1899        .filter(|value| !value.trim().is_empty())
1900        .map(parse_kernel_override)
1901        .transpose()
1902}
1903
1904fn configured_stripe_cap_bytes() -> Option<usize> {
1905    static CONFIGURED: std::sync::OnceLock<Option<usize>> = std::sync::OnceLock::new();
1906    *CONFIGURED.get_or_init(|| {
1907        std::env::var("WEAVER_PAR2_CREATE_STRIPE_MIB")
1908            .ok()
1909            .and_then(|value| value.trim().parse::<usize>().ok())
1910            .filter(|&mib| mib != 0)
1911            .and_then(|mib| mib.checked_mul(1024 * 1024))
1912    })
1913}
1914
1915struct BufferPlan {
1916    chunk_len: usize,
1917    /// The stride-aligned stripe length the buffers are sized for. Read by the
1918    /// plan-shape tests; every runtime use derives its own from `chunk_len`.
1919    #[cfg_attr(not(test), allow(dead_code))]
1920    aligned_chunk_len: usize,
1921    /// Distance between consecutive output rows in the output buffer:
1922    /// `aligned_chunk_len` plus the stripe skew (see [`SKEW_PERIOD_BYTES`]).
1923    /// Every row still holds exactly `aligned_chunk_len` payload bytes.
1924    row_stride: usize,
1925    staging_bytes: usize,
1926    output_bytes: usize,
1927    /// One staged source group, held once (the producer is a single thread).
1928    transfer_bytes: usize,
1929    data_bytes: usize,
1930    memory_bytes: usize,
1931    // Read only by the x86 accumulate path; other arches plan it but never
1932    // consume it.
1933    #[cfg_attr(not(target_arch = "x86_64"), allow(dead_code))]
1934    jit_build_limit_bytes: usize,
1935}
1936
1937impl BufferPlan {
1938    fn new_with_reserved(
1939        slice_size: usize,
1940        output_count: usize,
1941        contract: KernelContract,
1942        memory_limit: usize,
1943        factor_workspace_bytes: usize,
1944        jit_workspace_bytes: usize,
1945        jit_build_limit_bytes: usize,
1946    ) -> Result<Self> {
1947        if memory_limit == 0 {
1948            return Err(resource_limit("forward memory limit is zero"));
1949        }
1950        let reserved_bytes = checked_add(
1951            factor_workspace_bytes,
1952            jit_workspace_bytes,
1953            "forward persistent memory accounting overflow",
1954        )?;
1955        let stripe_memory_limit = memory_limit.checked_sub(reserved_bytes).ok_or_else(|| {
1956            resource_limit(format!(
1957                "forward persistent allocations need {reserved_bytes} bytes, limit is {memory_limit}"
1958            ))
1959        })?;
1960        // Unset by default, in which case this is exactly `stripe_memory_limit`
1961        // and nothing below changes; see `configured_stripe_cap_bytes`. A
1962        // tighter caller budget always still wins, and the cap is never allowed
1963        // to reject a shape the caller's budget admits: the loop below falls
1964        // back to `stripe_memory_limit` once the chunk cannot shrink further.
1965        let chosen_stripe_limit = match configured_stripe_cap_bytes() {
1966            Some(cap) => stripe_memory_limit.min(cap),
1967            None => stripe_memory_limit,
1968        };
1969        let mut chunk_len = if slice_size >= contract.stride {
1970            slice_size - slice_size % contract.stride
1971        } else {
1972            slice_size
1973        };
1974        chunk_len = chunk_len.max(2);
1975
1976        loop {
1977            let aligned_chunk_len = round_up(chunk_len.min(slice_size), contract.stride)?;
1978            // Staging is sized for the skewed lane stride whatever the family:
1979            // the packed XOR-JIT family lays its lanes exactly `aligned_len`
1980            // apart and simply leaves the tail unused, which keeps one plan
1981            // shape per stripe length instead of one per family.
1982            let skew = stripe_skew_bytes(aligned_chunk_len);
1983            let lane_alloc = checked_add(aligned_chunk_len, skew, "staging lane overflow")?;
1984            let row_stride = lane_alloc;
1985            let staging_bytes = checked_mul(
1986                contract.input_grouping,
1987                lane_alloc,
1988                "staging allocation overflow",
1989            )?;
1990            let output_bytes = checked_mul(output_count, row_stride, "output allocation overflow")?;
1991            let aligned_allocation_bytes = checked_mul(
1992                aligned_chunk_len.div_ceil(64),
1993                64,
1994                "aligned buffer allocation overflow",
1995            )?;
1996            let skewed_allocation_bytes = checked_add(
1997                aligned_allocation_bytes,
1998                skew,
1999                "aligned buffer allocation overflow",
2000            )?;
2001            // One transfer buffer per ring slot, each holding a whole input
2002            // batch of raw source bytes: the producer fills one while the
2003            // source hasher still holds the ones behind it.
2004            let transfer_bytes = checked_mul(
2005                contract.input_grouping,
2006                aligned_allocation_bytes,
2007                "transfer allocation overflow",
2008            )?;
2009            let data_bytes = checked_add(
2010                checked_mul(
2011                    configured_staging_areas(),
2012                    checked_mul(
2013                        contract.input_grouping,
2014                        skewed_allocation_bytes,
2015                        "staging allocation overflow",
2016                    )?,
2017                    "staging allocation overflow",
2018                )?,
2019                checked_add(
2020                    checked_mul(
2021                        output_count,
2022                        skewed_allocation_bytes,
2023                        "output allocation overflow",
2024                    )?,
2025                    checked_mul(
2026                        configured_staging_areas(),
2027                        transfer_bytes,
2028                        "transfer allocation overflow",
2029                    )?,
2030                    "forward buffer allocation overflow",
2031                )?,
2032                "forward buffer allocation overflow",
2033            )?;
2034            if data_bytes <= chosen_stripe_limit
2035                || (chunk_len <= 2 && data_bytes <= stripe_memory_limit)
2036            {
2037                return Ok(Self {
2038                    chunk_len: chunk_len.min(slice_size),
2039                    aligned_chunk_len,
2040                    row_stride,
2041                    staging_bytes,
2042                    output_bytes,
2043                    transfer_bytes,
2044                    data_bytes,
2045                    memory_bytes: reserved_bytes + data_bytes,
2046                    jit_build_limit_bytes,
2047                });
2048            }
2049            if chunk_len <= 2 {
2050                return Err(resource_limit(format!(
2051                    "forward persistent allocations and stripe buffers need {} bytes, limit is {memory_limit}",
2052                    reserved_bytes + data_bytes
2053                )));
2054            }
2055            if slice_size < contract.stride {
2056                chunk_len = 2;
2057                continue;
2058            }
2059            let bytes_per_aligned_byte = data_bytes / aligned_chunk_len;
2060            let max_aligned_len =
2061                (chosen_stripe_limit / bytes_per_aligned_byte) / contract.stride * contract.stride;
2062            let smaller_chunk_len = chunk_len.saturating_sub(contract.stride).max(2);
2063            chunk_len = max_aligned_len.max(2).min(smaller_chunk_len);
2064        }
2065    }
2066}
2067
2068fn select_kernel_for_memory(
2069    slice_size: usize,
2070    output_count: usize,
2071    source_count: usize,
2072    memory_limit: usize,
2073    requested: ForwardKernel,
2074) -> Result<(ResolvedKernel, BufferPlan)> {
2075    select_kernel_for_memory_with_capabilities(
2076        slice_size,
2077        output_count,
2078        source_count,
2079        memory_limit,
2080        requested,
2081        runtime_kernel_capabilities(),
2082    )
2083}
2084
2085fn select_kernel_for_memory_with_capabilities(
2086    slice_size: usize,
2087    output_count: usize,
2088    source_count: usize,
2089    memory_limit: usize,
2090    requested: ForwardKernel,
2091    capabilities: KernelCapabilities,
2092) -> Result<(ResolvedKernel, BufferPlan)> {
2093    let requested = match configured_kernel_override()? {
2094        Some(forced) => forced,
2095        None => requested,
2096    };
2097    let candidates = match requested {
2098        ForwardKernel::Auto => auto_kernel_candidates(capabilities),
2099        requested => vec![resolve_kernel_with_capabilities(requested, capabilities)?],
2100    };
2101    let mut last_error = None;
2102    for kernel in candidates {
2103        let contract = KernelContract::for_kernel(kernel);
2104        let factor_bytes = factor_workspace_bytes(kernel, source_count)?;
2105        // One active multi-row batch per band, recycled between input batches:
2106        // admission reserves one band-sized arena per band, and the build
2107        // limit is the largest band's arena bound.
2108        let (jit_bytes, jit_arena_bytes) = jit_workspace_bytes(kernel, output_count)?;
2109        match BufferPlan::new_with_reserved(
2110            slice_size,
2111            output_count,
2112            contract,
2113            memory_limit,
2114            factor_bytes,
2115            jit_bytes,
2116            jit_arena_bytes,
2117        ) {
2118            Ok(buffers) => return Ok((kernel, buffers)),
2119            Err(error) => last_error = Some(error),
2120        }
2121    }
2122    Err(last_error.unwrap_or_else(|| resource_limit("no forward arithmetic kernel is available")))
2123}
2124
2125fn auto_kernel_candidates(capabilities: KernelCapabilities) -> Vec<ResolvedKernel> {
2126    let mut kernels = Vec::with_capacity(4);
2127    let preferred = resolve_kernel_with_capabilities(ForwardKernel::Auto, capabilities)
2128        .expect("automatic forward kernel selection cannot fail");
2129    kernels.push(preferred);
2130    #[cfg(target_arch = "x86_64")]
2131    {
2132        if capabilities.avx2_jit && preferred != ResolvedKernel::XorJitAvx2 {
2133            kernels.push(ResolvedKernel::XorJitAvx2);
2134        }
2135        if capabilities.folded && preferred != ResolvedKernel::Folded {
2136            kernels.push(ResolvedKernel::Folded);
2137        }
2138    }
2139    let simd = resolve_kernel_with_capabilities(ForwardKernel::Simd, capabilities)
2140        .expect("direct grouped SIMD selection cannot fail");
2141    if preferred != simd {
2142        kernels.push(simd);
2143    }
2144    let portable = resolve_kernel_with_capabilities(ForwardKernel::Portable, capabilities)
2145        .expect("portable selection cannot fail");
2146    if preferred != portable {
2147        kernels.push(portable);
2148    }
2149    kernels
2150}
2151
2152struct FactorSource {
2153    /// PAR2 input-slice constants, one per source block. Every entry is an
2154    /// antilog value and therefore nonzero, which is what lets
2155    /// [`RowFactors::fill_row`] use the log form of `gf::pow` unconditionally.
2156    constants: Vec<u16>,
2157}
2158
2159impl FactorSource {
2160    fn new(source_count: usize) -> Self {
2161        Self {
2162            constants: gf::input_slice_constants(source_count),
2163        }
2164    }
2165
2166    /// Bind one input group's constants for a whole band of output rows.
2167    ///
2168    /// The discrete logs are the only part of `base^exponent` that depends on
2169    /// the source rather than the output row, so taking them once per (band,
2170    /// input group) removes `live_inputs` lookups into the 128 KiB log table
2171    /// from every output row — table traffic that also evicts the streaming
2172    /// kernel's working set.
2173    fn row_factors(&self, source_start: usize, live_inputs: usize) -> RowFactors {
2174        let mut logs = [0u16; MAX_INPUT_GROUPING];
2175        for (lane, log) in logs[..live_inputs].iter_mut().enumerate() {
2176            let constant = self.constants[source_start + lane];
2177            debug_assert_ne!(constant, 0, "input slice constants are never zero");
2178            *log = gf::log(constant);
2179        }
2180        RowFactors { logs, live_inputs }
2181    }
2182}
2183
2184/// One input group's per-source discrete logs, reused across a band's rows.
2185struct RowFactors {
2186    logs: [u16; MAX_INPUT_GROUPING],
2187    live_inputs: usize,
2188}
2189
2190impl RowFactors {
2191    fn fill_row(&self, exponent: RecoveryExponent, row: &mut [u16; MAX_INPUT_GROUPING]) {
2192        row.fill(0);
2193        for (factor, &log) in row[..self.live_inputs]
2194            .iter_mut()
2195            .zip(self.logs[..self.live_inputs].iter())
2196        {
2197            *factor = gf::pow_from_log(log, exponent);
2198        }
2199    }
2200}
2201
2202/// Stripes one forward pass will walk under the same budget the pass itself
2203/// resolves.
2204///
2205/// Creation asks this to decide whether a whole-file digest can be driven from
2206/// the encode feed: one stripe means the feed visits each file's bytes in file
2207/// order, more than one means it is stripe-major and cannot. Funnels through
2208/// `select_kernel_for_memory` exactly as `estimate_forward_memory` does, so
2209/// the answer is the shape the pass will actually take.
2210pub(crate) fn forward_stripe_count(
2211    slice_size: u64,
2212    source_count: usize,
2213    exponents: &[RecoveryExponent],
2214    memory_limit: usize,
2215    requested_kernel: ForwardKernel,
2216    transform_policy: TransformPolicy,
2217) -> Result<usize> {
2218    let output_count = exponents.len();
2219    if output_count == 0 {
2220        return Ok(0);
2221    }
2222    let slice_size = usize::try_from(slice_size)
2223        .map_err(|_| resource_limit("slice size exceeds addressable memory"))?;
2224    let (_, buffers) = select_kernel_for_memory(
2225        slice_size,
2226        output_count,
2227        source_count,
2228        memory_limit,
2229        requested_kernel,
2230    )?;
2231    // The arm that will run is the one whose pass count decides whether the
2232    // feed is in file order, so the transform's admission is resolved with the
2233    // same inputs the pass itself will use.
2234    if let Some(arm) = transform::admit(
2235        slice_size,
2236        source_count,
2237        exponents,
2238        memory_limit.min(buffers.memory_bytes),
2239        transform_policy,
2240    ) {
2241        return Ok(arm.shape().passes);
2242    }
2243    Ok(slice_size.div_ceil(buffers.chunk_len))
2244}
2245
2246pub(crate) fn estimate_forward_memory(
2247    slice_size: u64,
2248    source_count: usize,
2249    output_count: usize,
2250    memory_limit: usize,
2251    requested_kernel: ForwardKernel,
2252) -> Result<ForwardMemoryEstimate> {
2253    if output_count == 0 {
2254        return Ok(ForwardMemoryEstimate {
2255            factor_workspace_bytes: 0,
2256            jit_workspace_bytes: 0,
2257            stripe_buffer_bytes: 0,
2258            processing_peak_bytes: 0,
2259        });
2260    }
2261    let slice_size = usize::try_from(slice_size)
2262        .map_err(|_| resource_limit("slice size exceeds addressable memory"))?;
2263    let (kernel, buffers) = select_kernel_for_memory(
2264        slice_size,
2265        output_count,
2266        source_count,
2267        memory_limit,
2268        requested_kernel,
2269    )?;
2270    let factor_workspace_bytes = factor_workspace_bytes(kernel, source_count)?;
2271    let (jit_workspace_bytes, _) = jit_workspace_bytes(kernel, output_count)?;
2272    Ok(ForwardMemoryEstimate {
2273        factor_workspace_bytes,
2274        jit_workspace_bytes,
2275        stripe_buffer_bytes: buffers.data_bytes,
2276        processing_peak_bytes: buffers.memory_bytes,
2277    })
2278}
2279
2280#[repr(align(64))]
2281#[derive(Clone, Copy)]
2282struct AlignedCell(pub [u8; 64]);
2283
2284impl AlignedCell {
2285    fn as_ptr(&self) -> *const u8 {
2286        self.0.as_ptr()
2287    }
2288
2289    fn as_mut_ptr(&mut self) -> *mut u8 {
2290        self.0.as_mut_ptr()
2291    }
2292}
2293
2294pub(super) struct AlignedBuffer {
2295    cells: Vec<AlignedCell>,
2296    len: usize,
2297}
2298
2299impl AlignedBuffer {
2300    pub(super) fn new(len: usize) -> Self {
2301        Self {
2302            cells: vec![AlignedCell([0; 64]); len.div_ceil(64)],
2303            len,
2304        }
2305    }
2306
2307    pub(super) fn as_bytes(&self) -> &[u8] {
2308        let ptr = self
2309            .cells
2310            .first()
2311            .map_or_else(|| self.cells.as_ptr().cast::<u8>(), AlignedCell::as_ptr);
2312        unsafe { std::slice::from_raw_parts(ptr, self.len) }
2313    }
2314
2315    pub(super) fn as_bytes_mut(&mut self) -> &mut [u8] {
2316        let ptr = if self.cells.is_empty() {
2317            self.cells.as_mut_ptr().cast::<u8>()
2318        } else {
2319            self.cells[0].as_mut_ptr()
2320        };
2321        unsafe { std::slice::from_raw_parts_mut(ptr, self.len) }
2322    }
2323}
2324
2325#[allow(clippy::too_many_arguments)]
2326fn fill_staging<P: ForwardSourceProvider + ?Sized>(
2327    kernel: ResolvedKernel,
2328    staging: &mut AlignedBuffer,
2329    transfer: &mut AlignedBuffer,
2330    provider: &mut P,
2331    source_start: usize,
2332    stripe_offset: usize,
2333    actual_len: usize,
2334    aligned_len: usize,
2335    contract: KernelContract,
2336    slice_lens: &mut [usize; MAX_INPUT_GROUPING],
2337) -> Result<()> {
2338    let staging_bytes = staging.as_bytes_mut();
2339    staging_bytes.fill(0);
2340    // The transfer buffer holds the whole batch in its raw, unconverted form,
2341    // one 64-byte-aligned slot per source, so it can be handed to the source
2342    // hasher after the batch is staged instead of being hashed on this thread.
2343    // The staged layouts are no help to a hasher: the folded family scatters
2344    // six lanes into one interleaved stream and the packed family rewrites
2345    // every block, so only the transfer buffer still holds PAR2's own bytes.
2346    let slot_stride = transfer_slot_stride(aligned_len)?;
2347    let transfer_bytes = transfer.as_bytes_mut();
2348    if transfer_bytes.len() < contract.input_grouping.saturating_mul(slot_stride) {
2349        return Err(resource_limit(
2350            "transfer buffer is shorter than one input batch",
2351        ));
2352    }
2353    let lane_stride = lane_stride(contract, aligned_len);
2354    let layout = StagingLayout::new(contract, aligned_len, lane_stride);
2355    // One check for the whole batch instead of a per-lane one: every offset
2356    // below is bounded by the layout's last byte.
2357    let layout_bytes = layout
2358        .total_bytes()
2359        .ok_or_else(|| resource_limit("staging lane offset overflow"))?;
2360    if staging_bytes.len() < layout_bytes {
2361        return Err(resource_limit(
2362            "staging buffer is shorter than the batch layout",
2363        ));
2364    }
2365    let source_count = provider.source_count();
2366    *slice_lens = [0; MAX_INPUT_GROUPING];
2367
2368    for (lane, slice_len) in slice_lens[..contract.input_grouping].iter_mut().enumerate() {
2369        let slot_start = lane * slot_stride;
2370        transfer_bytes[slot_start..slot_start + aligned_len].fill(0);
2371        let source_index = source_start + lane;
2372        if source_index < source_count {
2373            *slice_len = provider.read_source_chunk(
2374                source_index,
2375                stripe_offset,
2376                &mut transfer_bytes[slot_start..slot_start + actual_len],
2377            )?;
2378        }
2379
2380        match kernel {
2381            ResolvedKernel::Portable | ResolvedKernel::Simd => {
2382                if layout.interleave == 1 {
2383                    let start = layout.group_base(lane);
2384                    staging_bytes[start..start + aligned_len]
2385                        .copy_from_slice(&transfer_bytes[slot_start..slot_start + aligned_len]);
2386                } else {
2387                    // Block-interleaved: this lane takes every `width`-th block
2388                    // of its group's stream, so a kernel pass over the group
2389                    // reads one sequential run. The family's stride is the
2390                    // block, so the stripe is a whole number of them.
2391                    const BLOCK: usize = gf_simd::INPUT_BATCH_BLOCK_BYTES;
2392                    debug_assert_eq!(aligned_len % BLOCK, 0, "interleaved stripe must be blocked");
2393                    let group = lane / layout.interleave;
2394                    let step = layout.group_width(group) * BLOCK;
2395                    let mut start = layout.group_base(group) + (lane % layout.interleave) * BLOCK;
2396                    for block in
2397                        transfer_bytes[slot_start..slot_start + aligned_len].chunks_exact(BLOCK)
2398                    {
2399                        staging_bytes[start..start + BLOCK].copy_from_slice(block);
2400                        start += step;
2401                    }
2402                }
2403            }
2404            #[cfg(target_arch = "x86_64")]
2405            ResolvedKernel::Folded => {
2406                let fold_group = lane / gf_simd::FOLDED_GROUP;
2407                let group_lane = lane % gf_simd::FOLDED_GROUP;
2408                let group_start = fold_group
2409                    .checked_mul(gf_simd::FOLDED_GROUP)
2410                    .and_then(|value| value.checked_mul(lane_stride))
2411                    .ok_or_else(|| resource_limit("folded staging offset overflow"))?;
2412                gf_simd::split_encode_scatter(
2413                    &transfer_bytes[slot_start..slot_start + aligned_len],
2414                    &mut staging_bytes
2415                        [group_start..group_start + aligned_len * gf_simd::FOLDED_GROUP],
2416                    group_lane,
2417                );
2418            }
2419            #[cfg(target_arch = "x86_64")]
2420            ResolvedKernel::XorJitAvx2 => {
2421                let width = reedsolomon_rs::xor_jit::JitWidth::Avx2;
2422                let block = width.block_bytes();
2423                debug_assert_eq!(aligned_len % block, 0);
2424                // `PackedRun` reads region `r` at `src + r * len`.
2425                debug_assert_eq!(lane_stride, aligned_len);
2426                let lane_start = lane
2427                    .checked_mul(lane_stride)
2428                    .ok_or_else(|| resource_limit("packed staging offset overflow"))?;
2429                for offset in (0..aligned_len).step_by(block) {
2430                    unsafe {
2431                        width.prepare_block(
2432                            &transfer_bytes[slot_start + offset..slot_start + offset + block],
2433                            &mut staging_bytes[lane_start + offset..lane_start + offset + block],
2434                        );
2435                    }
2436                }
2437            }
2438        }
2439    }
2440    Ok(())
2441}
2442
2443/// Distance between consecutive raw source slots in the transfer buffer: the
2444/// stripe rounded up to a whole cache line, so every slot keeps the alignment
2445/// the buffer base has and the split-layout scatter reads an aligned source.
2446fn transfer_slot_stride(aligned_len: usize) -> Result<usize> {
2447    round_up(aligned_len, 64)
2448}
2449
2450/// Hand one staged batch's raw slices to the observer, in runs the
2451/// multi-buffer digest kernel can lane.
2452fn observe_batch(
2453    observer: &mut dyn ForwardSourceObserver,
2454    bytes: &[u8],
2455    first_source_index: usize,
2456    live_inputs: usize,
2457    slot_stride: usize,
2458    slice_lens: &[usize; MAX_INPUT_GROUPING],
2459) -> Result<()> {
2460    let run_len = transfer_group_lanes().clamp(1, MAX_INPUT_GROUPING);
2461    let mut index = 0usize;
2462    while index < live_inputs {
2463        let run = run_len.min(live_inputs - index);
2464        let mut views: [&[u8]; MAX_INPUT_GROUPING] = [&[][..]; MAX_INPUT_GROUPING];
2465        for (slot, view) in views[..run].iter_mut().enumerate() {
2466            let start = (index + slot) * slot_stride;
2467            *view = &bytes[start..start + slice_lens[index + slot]];
2468        }
2469        observer.observe_slices(first_source_index + index, &views[..run])?;
2470        index += run;
2471    }
2472    Ok(())
2473}
2474
2475/// Sources actually present in the batch that starts at `source_start`.
2476fn live_batch_inputs(source_count: usize, source_start: usize, contract: KernelContract) -> usize {
2477    source_count
2478        .saturating_sub(source_start)
2479        .min(contract.input_grouping)
2480}
2481
2482#[allow(clippy::too_many_arguments)]
2483fn accumulate_batch(
2484    kernel: ResolvedKernel,
2485    output: &mut [u8],
2486    staging: &AlignedBuffer,
2487    factors: &FactorSource,
2488    exponents: &[RecoveryExponent],
2489    source_start: usize,
2490    live_inputs: usize,
2491    aligned_len: usize,
2492    output_stride: usize,
2493    contract: KernelContract,
2494    band_size: usize,
2495    #[cfg(target_arch = "x86_64")]
2496    jit_workspaces: &mut [reedsolomon_rs::xor_jit::packed::PackedJitWorkspace],
2497    #[cfg(target_arch = "x86_64")] jit_code_budget: usize,
2498) -> Result<()> {
2499    let output_count = exponents.len();
2500    // The chunked splits below are exact only over a whole-output slice, and
2501    // the workspace zip silently truncates if the caller's band shape ever
2502    // disagrees with the workspace count.
2503    debug_assert_eq!(output.len(), output_count * output_stride);
2504    #[cfg(target_arch = "x86_64")]
2505    debug_assert_eq!(
2506        jit_workspaces.len(),
2507        output_count.max(1).div_ceil(band_size)
2508    );
2509    if band_size >= output_count || output_count <= 1 {
2510        return accumulate_band(
2511            kernel,
2512            output,
2513            staging,
2514            factors,
2515            exponents,
2516            source_start,
2517            live_inputs,
2518            aligned_len,
2519            output_stride,
2520            contract,
2521            #[cfg(target_arch = "x86_64")]
2522            &mut jit_workspaces[0],
2523            #[cfg(target_arch = "x86_64")]
2524            jit_code_budget,
2525        );
2526    }
2527
2528    // Contiguous exponent bands map to contiguous output-major byte ranges,
2529    // so the chunked splits below hand each call a disjoint destination. The
2530    // split is walked in order here; the parallel pass drives the same
2531    // per-band function from [`encode_stripe_banded`], which is why the two
2532    // cannot produce different bytes.
2533    let band_bytes = checked_mul(band_size, output_stride, "band byte range overflow")?;
2534    let bands = output
2535        .chunks_mut(band_bytes)
2536        .zip(exponents.chunks(band_size));
2537    #[cfg(target_arch = "x86_64")]
2538    let bands = bands.zip(jit_workspaces.iter_mut());
2539    for band in bands {
2540        #[cfg(target_arch = "x86_64")]
2541        let ((band_output, band_exponents), jit_workspace) = band;
2542        #[cfg(not(target_arch = "x86_64"))]
2543        let (band_output, band_exponents) = band;
2544        accumulate_band(
2545            kernel,
2546            band_output,
2547            staging,
2548            factors,
2549            band_exponents,
2550            source_start,
2551            live_inputs,
2552            aligned_len,
2553            output_stride,
2554            contract,
2555            #[cfg(target_arch = "x86_64")]
2556            jit_workspace,
2557            #[cfg(target_arch = "x86_64")]
2558            jit_code_budget,
2559        )?;
2560    }
2561    Ok(())
2562}
2563
2564#[allow(clippy::too_many_arguments)]
2565fn accumulate_band(
2566    kernel: ResolvedKernel,
2567    output: &mut [u8],
2568    staging: &AlignedBuffer,
2569    factors: &FactorSource,
2570    exponents: &[RecoveryExponent],
2571    source_start: usize,
2572    live_inputs: usize,
2573    aligned_len: usize,
2574    output_stride: usize,
2575    contract: KernelContract,
2576    #[cfg(target_arch = "x86_64")]
2577    jit_workspace: &mut reedsolomon_rs::xor_jit::packed::PackedJitWorkspace,
2578    #[cfg(target_arch = "x86_64")] jit_code_budget: usize,
2579) -> Result<()> {
2580    let staging_bytes = staging.as_bytes();
2581    // An empty batch has no coefficients to build and no sources to read; the
2582    // per-arm row assembly below indexes lane 0 unconditionally.
2583    if live_inputs == 0 {
2584        return Ok(());
2585    }
2586    let lane_stride = lane_stride(contract, aligned_len);
2587    let layout = StagingLayout::new(contract, aligned_len, lane_stride);
2588    let mut row = [0u16; MAX_INPUT_GROUPING];
2589    match kernel {
2590        ResolvedKernel::Portable => {
2591            let row_factors = factors.row_factors(source_start, live_inputs);
2592            let mut rows = [[0u16; MAX_INPUT_GROUPING]; COEFF_ROWS];
2593            for (chunk_index, chunk) in exponents.chunks(COEFF_ROWS).enumerate() {
2594                for (slot, &exponent) in rows.iter_mut().zip(chunk) {
2595                    row_factors.fill_row(exponent, slot);
2596                }
2597                let first_output = chunk_index * COEFF_ROWS;
2598                for (tile_start, tile_len) in stripe_tiles(aligned_len, contract.tile_bytes) {
2599                    for (offset, row) in rows[..chunk.len()].iter().enumerate() {
2600                        let dst_start = (first_output + offset) * output_stride + tile_start;
2601                        scalar_accumulate(
2602                            &mut output[dst_start..dst_start + tile_len],
2603                            &staging_bytes[tile_start..],
2604                            lane_stride,
2605                            row,
2606                            live_inputs,
2607                            tile_len,
2608                        );
2609                    }
2610                }
2611            }
2612        }
2613        ResolvedKernel::Simd => {
2614            // One allocation for the whole band: the factors differ per row but
2615            // the capacity does not, so each row chunk rebuilds contents.
2616            let mut prepared: Vec<gf_simd::PreparedInputFactor> =
2617                Vec::with_capacity(COEFF_ROWS * live_inputs);
2618            let row_factors = factors.row_factors(source_start, live_inputs);
2619            for (chunk_index, chunk) in exponents.chunks(COEFF_ROWS).enumerate() {
2620                prepared.clear();
2621                for &exponent in chunk {
2622                    row_factors.fill_row(exponent, &mut row);
2623                    prepared.extend(
2624                        row[..live_inputs]
2625                            .iter()
2626                            .map(|&factor| gf_simd::prepare_input_factor(factor)),
2627                    );
2628                }
2629                let first_output = chunk_index * COEFF_ROWS;
2630                for (tile_start, tile_len) in stripe_tiles(aligned_len, contract.tile_bytes) {
2631                    for offset in 0..chunk.len() {
2632                        let dst_start = (first_output + offset) * output_stride + tile_start;
2633                        let row_base = offset * live_inputs;
2634                        if layout.interleave == 1 {
2635                            // Stack-resident: `live_inputs <=
2636                            // MAX_INPUT_GROUPING`, so the descriptor list never
2637                            // needs the heap. Building it per row used to cost
2638                            // one allocate/free pair per (output row, input
2639                            // group) — 3.3M of them on the 4096×819 create
2640                            // shape.
2641                            let inputs: [PreparedFactorSrc<'_>; MAX_INPUT_GROUPING] =
2642                                std::array::from_fn(|lane| {
2643                                    let clamped = lane.min(live_inputs - 1);
2644                                    let source_start_bytes =
2645                                        layout.group_base(clamped) + tile_start;
2646                                    PreparedFactorSrc {
2647                                        prepared: &prepared[row_base + clamped],
2648                                        src: &staging_bytes
2649                                            [source_start_bytes..source_start_bytes + tile_len],
2650                                    }
2651                                });
2652                            gf_simd::mul_acc_input_batch_prepared(
2653                                &mut output[dst_start..dst_start + tile_len],
2654                                &inputs[..live_inputs],
2655                            );
2656                            continue;
2657                        }
2658                        // One call per interleaved group: each is one kernel
2659                        // pass reading one contiguous stream, and no descriptor
2660                        // list is built at all because the group's factors are
2661                        // already contiguous in `prepared`.
2662                        for group in 0..layout.group_count() {
2663                            let first_lane = group * layout.interleave;
2664                            if first_lane >= live_inputs {
2665                                break;
2666                            }
2667                            let width = layout.group_width(group);
2668                            let live_in_group = (live_inputs - first_lane).min(width);
2669                            let (stream_start, stream_end) =
2670                                layout.group_tile(group, tile_start, tile_len);
2671                            gf_simd::mul_acc_input_batch_prepared_interleaved(
2672                                &mut output[dst_start..dst_start + tile_len],
2673                                &prepared
2674                                    [row_base + first_lane..row_base + first_lane + live_in_group],
2675                                &staging_bytes[stream_start..stream_end],
2676                                width,
2677                            );
2678                        }
2679                    }
2680                }
2681            }
2682        }
2683        #[cfg(target_arch = "x86_64")]
2684        ResolvedKernel::Folded => {
2685            debug_assert_eq!(contract.input_grouping, DEFAULT_INPUT_GROUPING);
2686            let groups = contract.input_grouping / gf_simd::FOLDED_GROUP;
2687            if groups > MAX_FOLDED_GROUPS {
2688                return Err(invalid_input(
2689                    "folded input grouping exceeds the reserved group count",
2690                ));
2691            }
2692            let mut affine = Vec::with_capacity(COEFF_ROWS * live_inputs);
2693            let mut shuffle2x = Vec::with_capacity(COEFF_ROWS * live_inputs);
2694            // Hoisted: a process-wide capability answer, not a per-row one.
2695            let uses_gfni = gf_simd::folded_uses_gfni();
2696            let row_factors = factors.row_factors(source_start, live_inputs);
2697            // Rebuilt per tile, not per (tile, output row): the views only
2698            // depend on where the tile starts.
2699            let mut staging_views: Vec<&[u8]> = Vec::with_capacity(groups);
2700            for (chunk_index, chunk) in exponents.chunks(COEFF_ROWS).enumerate() {
2701                affine.clear();
2702                shuffle2x.clear();
2703                for &exponent in chunk {
2704                    row_factors.fill_row(exponent, &mut row);
2705                    if uses_gfni {
2706                        affine.extend(
2707                            row[..live_inputs]
2708                                .iter()
2709                                .map(|&factor| gf_simd::precompute_affine_matrices(factor)),
2710                        );
2711                    } else {
2712                        shuffle2x.extend(
2713                            row[..live_inputs]
2714                                .iter()
2715                                .map(|&factor| gf_simd::precompute_shuffle2x_tables(factor)),
2716                        );
2717                    }
2718                }
2719                let first_output = chunk_index * COEFF_ROWS;
2720                for (tile_start, tile_len) in stripe_tiles(aligned_len, contract.tile_bytes) {
2721                    staging_views.clear();
2722                    staging_views.extend((0..groups).map(|group| {
2723                        // Within a group the six lanes are interleaved by
2724                        // `SPLIT_BLOCK_BYTES` blocks, so the tile that starts at
2725                        // logical byte `tile_start` of every lane starts at
2726                        // `tile_start * FOLDED_GROUP` of the interleaved stream.
2727                        let start = group * gf_simd::FOLDED_GROUP * lane_stride
2728                            + tile_start * gf_simd::FOLDED_GROUP;
2729                        &staging_bytes[start..start + gf_simd::FOLDED_GROUP * tile_len]
2730                    }));
2731                    for offset in 0..chunk.len() {
2732                        let dst_start = (first_output + offset) * output_stride + tile_start;
2733                        let row_base = offset * live_inputs;
2734                        if uses_gfni {
2735                            // Stack-resident for the same reason as the SIMD
2736                            // arm's descriptor list: `groups` is bounded by the
2737                            // compile-time input grouping, so the reference
2738                            // table costs no allocator traffic per output row.
2739                            let matrix_sets: [[&gf_simd::AffineMulMatrices; gf_simd::FOLDED_GROUP];
2740                                MAX_FOLDED_GROUPS] = std::array::from_fn(|group| {
2741                                std::array::from_fn(|lane| {
2742                                    let source_index = group * gf_simd::FOLDED_GROUP + lane;
2743                                    affine
2744                                        .get(row_base + source_index)
2745                                        .filter(|_| source_index < live_inputs)
2746                                        .unwrap_or(&gf_simd::ZERO_AFFINE)
2747                                })
2748                            });
2749                            gf_simd::mul_acc_folded_batch(
2750                                &mut output[dst_start..dst_start + tile_len],
2751                                &staging_views,
2752                                &matrix_sets[..groups],
2753                            );
2754                        } else {
2755                            let table_sets: [[&gf_simd::Shuffle2xTables; gf_simd::FOLDED_GROUP];
2756                                MAX_FOLDED_GROUPS] = std::array::from_fn(|group| {
2757                                std::array::from_fn(|lane| {
2758                                    let source_index = group * gf_simd::FOLDED_GROUP + lane;
2759                                    shuffle2x
2760                                        .get(row_base + source_index)
2761                                        .filter(|_| source_index < live_inputs)
2762                                        .unwrap_or(&gf_simd::ZERO_SHUFFLE2X)
2763                                })
2764                            });
2765                            gf_simd::mul_acc_shuffle2x_batch(
2766                                &mut output[dst_start..dst_start + tile_len],
2767                                &staging_views,
2768                                &table_sets[..groups],
2769                            );
2770                        }
2771                    }
2772                }
2773            }
2774        }
2775        #[cfg(target_arch = "x86_64")]
2776        ResolvedKernel::XorJitAvx2 => {
2777            // Admission covers the workspace arena and stripe buffers before
2778            // any sink mutation. A later W^X/code-generation or execution
2779            // error is terminal for this pass; it is not a post-admission
2780            // tier downgrade.
2781            //
2782            // One sealed multi-row batch per input batch — every row of this
2783            // band in a single build, recycled before the next batch — never
2784            // a build per output row (per-row churn measured at 60% of create
2785            // on c5 pass 2) and never a pass-retained store (measured
2786            // self-rejecting at real job shapes on c5 pass 3).
2787            //
2788            // No tile loop: `PackedRun` addresses source region `r` at
2789            // `src + r * len`, so the family consumes the whole stripe per
2790            // call by contract.
2791            debug_assert_eq!(contract.tile_bytes, UNTILED);
2792            debug_assert_eq!(lane_stride, aligned_len);
2793            debug_assert_eq!(contract.input_grouping, DEFAULT_INPUT_GROUPING);
2794            let width = reedsolomon_rs::xor_jit::JitWidth::Avx2;
2795            let row_factors = factors.row_factors(source_start, live_inputs);
2796            let rows: Vec<[u16; DEFAULT_INPUT_GROUPING]> = exponents
2797                .iter()
2798                .map(|&exponent| {
2799                    // Full-width row: zero tail factors keep their source
2800                    // positions for the packed group shape. The family's
2801                    // grouping is the packed width, so the wide row's tail
2802                    // beyond it is always zero.
2803                    let mut wide = [0u16; MAX_INPUT_GROUPING];
2804                    row_factors.fill_row(exponent, &mut wide);
2805                    let mut row = [0u16; DEFAULT_INPUT_GROUPING];
2806                    row.copy_from_slice(&wide[..DEFAULT_INPUT_GROUPING]);
2807                    row
2808                })
2809                .collect();
2810            let row_refs: Vec<&[u16]> = rows.iter().map(|row| &row[..]).collect();
2811            let batch = jit_workspace
2812                .build(width, &row_refs, jit_code_budget.max(1))
2813                .map_err(|error| jit_build_error(error.to_string()))?;
2814            for output_index in 0..exponents.len() {
2815                let dst_start = output_index * output_stride;
2816                let code = batch
2817                    .row(output_index)
2818                    .ok_or_else(|| invalid_input("packed XOR-JIT output row missing"))?;
2819                unsafe {
2820                    width
2821                        .try_run_packed(
2822                            code,
2823                            &mut reedsolomon_rs::xor_jit::packed::PackedScratch::default(),
2824                            reedsolomon_rs::xor_jit::packed::PackedRun {
2825                                packed_regions: contract.input_grouping,
2826                                live_regions: live_inputs,
2827                                dst: output[dst_start..dst_start + aligned_len].as_mut_ptr(),
2828                                src: staging_bytes.as_ptr(),
2829                                len: aligned_len,
2830                                prefetch_in: Some(staging_bytes.as_ptr()),
2831                                prefetch_out: None,
2832                            },
2833                        )
2834                        .map_err(|error| jit_build_error(error.to_string()))?;
2835                }
2836            }
2837            jit_workspace
2838                .recycle(batch)
2839                .map_err(|error| jit_build_error(error.to_string()))?;
2840        }
2841    }
2842    Ok(())
2843}
2844
2845/// Word-wise accumulate of one tile.
2846///
2847/// `staging` starts at the tile's first byte of lane 0 and `staging_stride` is
2848/// the distance between lanes in the whole stripe, which is the stripe length
2849/// rather than the tile length whenever the stripe is tiled.
2850fn scalar_accumulate(
2851    dst: &mut [u8],
2852    staging: &[u8],
2853    staging_stride: usize,
2854    row: &[u16],
2855    live_inputs: usize,
2856    len: usize,
2857) {
2858    for word in 0..len / 2 {
2859        let mut value = u16::from_le_bytes([dst[word * 2], dst[word * 2 + 1]]);
2860        for (lane, &factor) in row.iter().take(live_inputs).enumerate() {
2861            let source_offset = lane * staging_stride + word * 2;
2862            let source = u16::from_le_bytes([staging[source_offset], staging[source_offset + 1]]);
2863            value ^= gf::mul(source, factor);
2864        }
2865        dst[word * 2..word * 2 + 2].copy_from_slice(&value.to_le_bytes());
2866    }
2867}
2868
2869fn finish_output(
2870    kernel: ResolvedKernel,
2871    output: &mut [u8],
2872    output_stride: usize,
2873    aligned_len: usize,
2874    output_count: usize,
2875) -> Result<()> {
2876    debug_assert_eq!(output.len(), output_count * output_stride);
2877    finish_band_rows(kernel, output, output_stride, aligned_len, output_count)
2878}
2879
2880/// Finish one contiguous run of output rows.
2881///
2882/// Row-local by construction on every family that needs it, which is what
2883/// lets each band worker finish its own rows at the end of a stripe instead of
2884/// a second banded pass over the whole output.
2885fn finish_band_rows(
2886    kernel: ResolvedKernel,
2887    output: &mut [u8],
2888    output_stride: usize,
2889    aligned_len: usize,
2890    output_count: usize,
2891) -> Result<()> {
2892    #[cfg(not(target_arch = "x86_64"))]
2893    {
2894        let _ = (kernel, output, output_stride, aligned_len, output_count);
2895    }
2896
2897    #[cfg(target_arch = "x86_64")]
2898    {
2899        if matches!(kernel, ResolvedKernel::Portable | ResolvedKernel::Simd) {
2900            return Ok(());
2901        }
2902        return finish_band(kernel, output, output_stride, aligned_len, output_count);
2903    }
2904    #[allow(unreachable_code)]
2905    Ok(())
2906}
2907
2908#[cfg(target_arch = "x86_64")]
2909fn finish_band(
2910    kernel: ResolvedKernel,
2911    output: &mut [u8],
2912    output_stride: usize,
2913    aligned_len: usize,
2914    output_count: usize,
2915) -> Result<()> {
2916    for output_index in 0..output_count {
2917        let start = output_index
2918            .checked_mul(output_stride)
2919            .ok_or_else(|| resource_limit("output finish offset overflow"))?;
2920        let end = start
2921            .checked_add(aligned_len)
2922            .ok_or_else(|| resource_limit("output finish end overflow"))?;
2923        let dst = &mut output[start..end];
2924
2925        match kernel {
2926            ResolvedKernel::Portable | ResolvedKernel::Simd => {}
2927            ResolvedKernel::Folded => {
2928                gf_simd::altmap_decode(dst);
2929            }
2930            ResolvedKernel::XorJitAvx2 => {
2931                let width = reedsolomon_rs::xor_jit::JitWidth::Avx2;
2932                let block = width.block_bytes();
2933                for offset in (0..aligned_len).step_by(block) {
2934                    unsafe { width.finish_block(&mut dst[offset..offset + block]) };
2935                }
2936            }
2937        }
2938    }
2939    Ok(())
2940}
2941
2942fn validate_provider<P: ForwardSourceProvider + ?Sized>(
2943    provider: &P,
2944    slice_size: usize,
2945) -> Result<()> {
2946    let source_count = provider.source_count();
2947    if source_count > MAX_TOTAL_INPUT_SLICES {
2948        return Err(resource_limit(format!(
2949            "input slice count {} exceeds {MAX_TOTAL_INPUT_SLICES}",
2950            source_count
2951        )));
2952    }
2953    for source_index in 0..source_count {
2954        if provider.source_slice_len(source_index)? > slice_size {
2955            return Err(invalid_input(
2956                "an input slice is longer than the configured slice size",
2957            ));
2958        }
2959    }
2960    Ok(())
2961}
2962
2963fn check_cancel(options: &ForwardEncoderOptions) -> Result<()> {
2964    if options
2965        .cancel
2966        .as_ref()
2967        .is_some_and(CancellationToken::is_cancelled)
2968    {
2969        Err(Par2Error::Cancelled)
2970    } else {
2971        Ok(())
2972    }
2973}
2974
2975fn report_progress(
2976    options: &ForwardEncoderOptions,
2977    current: u32,
2978    total: u32,
2979    bytes_processed: u64,
2980    total_bytes: u64,
2981) {
2982    if let Some(progress) = &options.progress {
2983        progress(ProgressUpdate {
2984            stage: ProgressStage::Creating,
2985            current,
2986            total,
2987            bytes_processed,
2988            total_bytes: Some(total_bytes),
2989            phase: ProgressPhase::RecoveryEncode,
2990        });
2991    }
2992}
2993
2994#[cfg(test)]
2995struct VecRecoverySink {
2996    blocks: Vec<ForwardRecoveryBlock>,
2997    slice_size: usize,
2998}
2999
3000#[cfg(test)]
3001impl VecRecoverySink {
3002    fn new(exponents: &[RecoveryExponent], slice_size: usize) -> Self {
3003        Self {
3004            blocks: exponents
3005                .iter()
3006                .map(|&exponent| ForwardRecoveryBlock {
3007                    exponent,
3008                    data: vec![0; slice_size],
3009                })
3010                .collect(),
3011            slice_size,
3012        }
3013    }
3014}
3015
3016#[cfg(test)]
3017impl ForwardRecoverySink for VecRecoverySink {
3018    fn write_recovery_chunk(
3019        &mut self,
3020        output_index: usize,
3021        exponent: RecoveryExponent,
3022        offset: u64,
3023        data: &[u8],
3024    ) -> Result<()> {
3025        let block = self
3026            .blocks
3027            .get_mut(output_index)
3028            .ok_or_else(|| invalid_input("recovery output index is out of order"))?;
3029        if block.exponent != exponent {
3030            return Err(invalid_input("recovery exponent changed during encoding"));
3031        }
3032        let start =
3033            usize::try_from(offset).map_err(|_| resource_limit("stripe offset overflow"))?;
3034        let end = start
3035            .checked_add(data.len())
3036            .ok_or_else(|| resource_limit("recovery chunk end overflow"))?;
3037        if end > self.slice_size {
3038            return Err(invalid_input(
3039                "recovery chunk exceeds the configured slice size",
3040            ));
3041        }
3042        block.data[start..end].copy_from_slice(data);
3043        Ok(())
3044    }
3045}
3046
3047fn round_up(value: usize, alignment: usize) -> Result<usize> {
3048    if alignment == 0 {
3049        return Err(invalid_input("zero alignment"));
3050    }
3051    value
3052        .checked_add(alignment - 1)
3053        .map(|value| value / alignment * alignment)
3054        .ok_or_else(|| resource_limit("aligned length overflow"))
3055}
3056
3057fn checked_mul(left: usize, right: usize, reason: &'static str) -> Result<usize> {
3058    left.checked_mul(right)
3059        .ok_or_else(|| resource_limit(reason))
3060}
3061
3062fn checked_add(left: usize, right: usize, reason: &'static str) -> Result<usize> {
3063    left.checked_add(right)
3064        .ok_or_else(|| resource_limit(reason))
3065}
3066
3067fn invalid_input(reason: impl Into<String>) -> Par2Error {
3068    Par2Error::ReedSolomonError {
3069        reason: reason.into(),
3070    }
3071}
3072
3073fn resource_limit(reason: impl Into<String>) -> Par2Error {
3074    Par2Error::ResourceLimitExceeded {
3075        reason: reason.into(),
3076    }
3077}
3078
3079#[cfg(target_arch = "x86_64")]
3080fn unavailable_kernel(name: &'static str) -> Par2Error {
3081    Par2Error::ReedSolomonError {
3082        reason: format!("forward arithmetic kernel unavailable: {name}"),
3083    }
3084}
3085
3086#[cfg(target_arch = "x86_64")]
3087fn jit_build_error(reason: String) -> Par2Error {
3088    Par2Error::ReedSolomonError {
3089        reason: format!("forward packed arithmetic dispatch failed: {reason}"),
3090    }
3091}
3092
3093#[cfg(test)]
3094mod tests {
3095    use super::*;
3096
3097    fn test_sources() -> Vec<Vec<u8>> {
3098        (0..19usize)
3099            .map(|source| {
3100                (0..(73 + source * 11).min(256))
3101                    .map(|index| (index.wrapping_mul(17) ^ (source * 29)) as u8)
3102                    .collect()
3103            })
3104            .collect()
3105    }
3106
3107    fn encode_with_kernel(
3108        sources: &[Vec<u8>],
3109        kernel: ForwardKernel,
3110    ) -> Result<Vec<ForwardRecoveryBlock>> {
3111        let refs = sources.iter().map(Vec::as_slice).collect::<Vec<_>>();
3112        let encoder = ForwardEncoder::new(256, vec![0, 1, 2, 7, 31])?;
3113        encoder.encode(
3114            &refs,
3115            &ForwardEncoderOptions {
3116                memory_limit: Some(4 * 1024 * 1024),
3117                kernel,
3118                ..ForwardEncoderOptions::default()
3119            },
3120        )
3121    }
3122
3123    #[test]
3124    fn portable_output_matches_every_available_cpu_path() {
3125        let sources = test_sources();
3126        let portable = encode_with_kernel(&sources, ForwardKernel::Portable).unwrap();
3127        for kernel in ForwardEncoder::available_kernels() {
3128            let actual = encode_with_kernel(&sources, kernel).unwrap();
3129            assert_eq!(actual, portable, "kernel {kernel:?} differs from portable");
3130        }
3131    }
3132
3133    #[test]
3134    fn automatic_selection_matches_its_explicit_kernel() {
3135        let sources = test_sources();
3136        let auto = encode_with_kernel(&sources, ForwardKernel::Auto).unwrap();
3137        let encoder = ForwardEncoder::new(256, vec![0, 1, 2, 7, 31]).unwrap();
3138        let selected = encoder.selected_kernel(ForwardKernel::Auto).unwrap();
3139        let explicit = encode_with_kernel(&sources, selected).unwrap();
3140        assert_eq!(auto, explicit, "automatic kernel {selected:?} differs");
3141    }
3142
3143    /// The env override's value mapping, tested without process-global state.
3144    #[test]
3145    fn kernel_override_values_parse_and_reject() {
3146        assert!(matches!(
3147            parse_kernel_override("auto"),
3148            Ok(ForwardKernel::Auto)
3149        ));
3150        assert!(matches!(
3151            parse_kernel_override(" Portable "),
3152            Ok(ForwardKernel::Portable)
3153        ));
3154        assert!(matches!(
3155            parse_kernel_override("SIMD"),
3156            Ok(ForwardKernel::Simd)
3157        ));
3158        #[cfg(target_arch = "x86_64")]
3159        {
3160            assert!(matches!(
3161                parse_kernel_override("folded"),
3162                Ok(ForwardKernel::Folded)
3163            ));
3164            assert!(matches!(
3165                parse_kernel_override("xor-jit-avx2"),
3166                Ok(ForwardKernel::XorJitAvx2)
3167            ));
3168            // The AVX-512 JIT is removed; its old name must fail loudly, not
3169            // silently select something else.
3170            assert!(parse_kernel_override("xor-jit-avx512").is_err());
3171        }
3172        assert!(parse_kernel_override("fast").is_err());
3173        assert!(parse_kernel_override("").is_err());
3174    }
3175
3176    /// The stripe hand-off must give every band every batch, in order, and
3177    /// must not let the producer refill an area a band is still reading.
3178    ///
3179    /// The marker byte is the witness: the producer stamps the batch index
3180    /// into the area it just filled, and every band asserts the stamp it sees
3181    /// is the batch it asked for. A ring that reclaimed an area early would
3182    /// overwrite a live area with the *next* batch's stamp, which is exactly
3183    /// the failure this catches; `Arc::get_mut` on the producer side is the
3184    /// same reclaim proof the encoder relies on.
3185    #[test]
3186    fn the_stripe_feed_reclaims_an_area_only_after_every_band_is_done() {
3187        const BATCHES: usize = 37;
3188        for band_count in [1usize, 2, 5] {
3189            let depth = configured_staging_areas();
3190            let feed = StripeFeed::new(band_count, depth);
3191            let feed = &feed;
3192            let mut areas: Vec<std::sync::Arc<AlignedBuffer>> = (0..depth)
3193                .map(|_| std::sync::Arc::new(AlignedBuffer::new(64)))
3194                .collect();
3195            let mut slots: Vec<std::sync::Arc<TransferSlot>> = (0..depth)
3196                .map(|_| std::sync::Arc::new(TransferSlot::new(64)))
3197                .collect();
3198            // Every batch's hashing turn, in the order the observer would have
3199            // been called: the bands push to this under the turn alone.
3200            let hashed = std::sync::Mutex::new(Vec::<usize>::with_capacity(BATCHES));
3201            let hashed = &hashed;
3202            // Bands record what they saw instead of asserting on their own
3203            // thread: a band that unwound mid-stripe would never release its
3204            // area and the producer would then block on a ring that can never
3205            // drain, so a known-bad injection has to FAIL this test rather
3206            // than hang it.
3207            let faults = std::sync::Mutex::new(Vec::<String>::new());
3208            let faults = &faults;
3209            std::thread::scope(|scope| {
3210                for band_index in 0..band_count {
3211                    scope.spawn(move || {
3212                        let note = |fault: String| faults.lock().expect("uncontended").push(fault);
3213                        for batch in 0..BATCHES {
3214                            let Some(ticket) = feed.acquire(batch) else {
3215                                note(format!("batch {batch}: no failure is injected"));
3216                                return;
3217                            };
3218                            let stamp = (batch % 251) as u8;
3219                            if ticket.source_start != batch * 7 {
3220                                note(format!("batch {batch}: batch order"));
3221                            }
3222                            if ticket.staging.as_bytes()[0] != stamp {
3223                                note(format!(
3224                                    "batch {batch}: area was refilled while a band still held it"
3225                                ));
3226                            }
3227                            if batch % band_count == band_index {
3228                                if !feed.wait_for_hash_turn(batch) {
3229                                    note(format!("batch {batch}: the hashing turn never came"));
3230                                    return;
3231                                }
3232                                if ticket.transfer.buffer.as_bytes()[0] != stamp {
3233                                    note(format!(
3234                                        "batch {batch}: transfer slot was refilled while a band still held it"
3235                                    ));
3236                                }
3237                                hashed.lock().expect("uncontended").push(batch);
3238                                feed.finish_hash_turn(batch);
3239                            }
3240                            drop(ticket);
3241                            feed.release(batch);
3242                        }
3243                    });
3244                }
3245                for batch in 0..BATCHES {
3246                    assert!(feed.wait_for_area(batch));
3247                    let area = batch % depth;
3248                    let buffer = std::sync::Arc::get_mut(&mut areas[area])
3249                        .expect("every band released the area before it was reclaimed");
3250                    buffer.as_bytes_mut()[0] = (batch % 251) as u8;
3251                    let slot = std::sync::Arc::get_mut(&mut slots[area])
3252                        .expect("every band released the transfer slot before it was reclaimed");
3253                    slot.buffer.as_bytes_mut()[0] = (batch % 251) as u8;
3254                    feed.publish(
3255                        batch,
3256                        BatchTicket {
3257                            staging: std::sync::Arc::clone(&areas[area]),
3258                            transfer: std::sync::Arc::clone(&slots[area]),
3259                            source_start: batch * 7,
3260                            live_inputs: 1,
3261                        },
3262                    );
3263                }
3264            });
3265            assert!(
3266                faults.lock().expect("no band panicked").is_empty(),
3267                "{:?}",
3268                faults.lock().expect("no band panicked")
3269            );
3270            assert_eq!(
3271                hashed.lock().expect("no band panicked").as_slice(),
3272                (0..BATCHES).collect::<Vec<_>>(),
3273                "the hashing turn must reach the observer once per batch, in index order"
3274            );
3275        }
3276    }
3277
3278    /// A failed pass must release every side of the hand-off. Without the
3279    /// flag, `acquire` waits for a publish that will never come,
3280    /// `wait_for_area` waits for a completion that will never come, and
3281    /// `wait_for_hash_turn` waits for a turn whose owner has already stopped.
3282    #[test]
3283    fn a_failed_pass_releases_both_sides_of_the_feed() {
3284        let feed = StripeFeed::new(2, configured_staging_areas());
3285        feed.fail();
3286        assert!(feed.acquire(0).is_none(), "a band must stop on failure");
3287        assert!(
3288            !feed.wait_for_hash_turn(7),
3289            "a band owing a hashing turn must stop on failure"
3290        );
3291        assert!(
3292            !feed.wait_for_area(configured_staging_areas()),
3293            "the producer must stop on failure"
3294        );
3295    }
3296
3297    /// The banded accumulate/finish split must be byte-identical to the
3298    /// sequential pass for every runtime kernel, including an uneven trailing
3299    /// band (seven outputs over three bands).
3300    #[test]
3301    fn banded_accumulation_matches_sequential() {
3302        let sources = test_sources();
3303        let refs = sources.iter().map(Vec::as_slice).collect::<Vec<_>>();
3304        let exponents: Vec<RecoveryExponent> = vec![0, 1, 2, 7, 31, 64, 100];
3305        for requested in ForwardEncoder::available_kernels() {
3306            let resolved =
3307                resolve_kernel_with_capabilities(requested, runtime_kernel_capabilities())
3308                    .expect("advertised kernels resolve");
3309            let contract = KernelContract::for_kernel(resolved);
3310            let aligned_len = round_up(256, contract.stride).unwrap();
3311
3312            let mut passes = Vec::new();
3313            // band_size = 7 covers the sequential path; 3 exercises uneven
3314            // banding (bands of 3, 3, 1 outputs).
3315            for band_size in [7usize, 3] {
3316                let mut provider = InMemorySourceProvider { sources: &refs };
3317                let mut staging = AlignedBuffer::new(
3318                    contract.input_grouping * lane_stride(contract, aligned_len),
3319                );
3320                let mut transfer = AlignedBuffer::new(
3321                    contract.input_grouping * transfer_slot_stride(aligned_len).unwrap(),
3322                );
3323                fill_staging(
3324                    resolved,
3325                    &mut staging,
3326                    &mut transfer,
3327                    &mut provider,
3328                    0,
3329                    0,
3330                    256,
3331                    aligned_len,
3332                    contract,
3333                    &mut [0usize; MAX_INPUT_GROUPING],
3334                )
3335                .unwrap();
3336                let factors = FactorSource::new(refs.len());
3337                let mut output = AlignedBuffer::new(exponents.len() * aligned_len);
3338                #[cfg(target_arch = "x86_64")]
3339                let mut jit_workspaces: Vec<
3340                    reedsolomon_rs::xor_jit::packed::PackedJitWorkspace,
3341                > = (0..exponents.len().div_ceil(band_size))
3342                    .map(|_| Default::default())
3343                    .collect();
3344                accumulate_batch(
3345                    resolved,
3346                    output.as_bytes_mut(),
3347                    &staging,
3348                    &factors,
3349                    &exponents,
3350                    0,
3351                    contract.input_grouping.min(refs.len()),
3352                    aligned_len,
3353                    aligned_len,
3354                    contract,
3355                    band_size,
3356                    #[cfg(target_arch = "x86_64")]
3357                    &mut jit_workspaces,
3358                    #[cfg(target_arch = "x86_64")]
3359                    usize::MAX,
3360                )
3361                .unwrap();
3362                finish_output(
3363                    resolved,
3364                    output.as_bytes_mut(),
3365                    aligned_len,
3366                    aligned_len,
3367                    exponents.len(),
3368                )
3369                .unwrap();
3370                passes.push(output.as_bytes().to_vec());
3371            }
3372            assert_eq!(
3373                passes[0], passes[1],
3374                "kernel {requested:?} banded output differs from sequential"
3375            );
3376        }
3377    }
3378
3379    /// Every emitted tile is stride-aligned and the ranges tile the stripe
3380    /// exactly once, including a stripe that is not a whole number of tiles.
3381    #[test]
3382    fn stripe_tiles_cover_the_stripe_exactly() {
3383        for (aligned_len, tile) in [
3384            (4096usize, 4096usize),
3385            (4096, 8192),
3386            (4096, UNTILED),
3387            (10 * 1024, 4096),
3388            (32, 4096),
3389            (0, 4096),
3390        ] {
3391            let ranges: Vec<(usize, usize)> = stripe_tiles(aligned_len, tile).collect();
3392            let mut next = 0usize;
3393            for (start, len) in &ranges {
3394                assert_eq!(*start, next, "tiles are contiguous");
3395                assert!(*len > 0 && *len <= tile.min(aligned_len).max(1));
3396                next += len;
3397            }
3398            assert_eq!(next, aligned_len, "tiles cover the stripe");
3399            if aligned_len > 0 {
3400                // Only the final tile may be short.
3401                for (_, len) in &ranges[..ranges.len() - 1] {
3402                    assert_eq!(*len, tile.min(aligned_len));
3403                }
3404            }
3405        }
3406    }
3407
3408    /// Tiling one in-memory stripe is a pure loop transformation: for every
3409    /// runtime kernel whose family is tiled, the accumulated bytes must not
3410    /// depend on the tile size, including tiles that do not divide the stripe.
3411    #[test]
3412    fn stripe_tiling_matches_untiled_accumulation() {
3413        const SLICE: usize = 40 * 1024;
3414        let sources: Vec<Vec<u8>> = (0..14usize)
3415            .map(|source| {
3416                (0..SLICE)
3417                    .map(|index| (index.wrapping_mul(31) ^ (source * 131)) as u8)
3418                    .collect()
3419            })
3420            .collect();
3421        let refs = sources.iter().map(Vec::as_slice).collect::<Vec<_>>();
3422        let exponents: Vec<RecoveryExponent> = vec![0, 1, 2, 7, 31];
3423        for requested in ForwardEncoder::available_kernels() {
3424            let resolved =
3425                resolve_kernel_with_capabilities(requested, runtime_kernel_capabilities())
3426                    .expect("advertised kernels resolve");
3427            let base = KernelContract::for_kernel(resolved);
3428            if base.tile_bytes == UNTILED {
3429                continue;
3430            }
3431            let aligned_len = round_up(SLICE, base.stride).unwrap();
3432            let mut passes = Vec::new();
3433            for tile_bytes in [UNTILED, 8192, 4096, 96, base.stride] {
3434                let contract = KernelContract { tile_bytes, ..base };
3435                let mut provider = InMemorySourceProvider { sources: &refs };
3436                let mut staging = AlignedBuffer::new(
3437                    contract.input_grouping * lane_stride(contract, aligned_len),
3438                );
3439                let mut transfer = AlignedBuffer::new(
3440                    contract.input_grouping * transfer_slot_stride(aligned_len).unwrap(),
3441                );
3442                fill_staging(
3443                    resolved,
3444                    &mut staging,
3445                    &mut transfer,
3446                    &mut provider,
3447                    0,
3448                    0,
3449                    SLICE,
3450                    aligned_len,
3451                    contract,
3452                    &mut [0usize; MAX_INPUT_GROUPING],
3453                )
3454                .unwrap();
3455                let factors = FactorSource::new(refs.len());
3456                let mut output = AlignedBuffer::new(exponents.len() * aligned_len);
3457                #[cfg(target_arch = "x86_64")]
3458                let mut jit_workspaces: Vec<
3459                    reedsolomon_rs::xor_jit::packed::PackedJitWorkspace,
3460                > = vec![Default::default()];
3461                accumulate_batch(
3462                    resolved,
3463                    output.as_bytes_mut(),
3464                    &staging,
3465                    &factors,
3466                    &exponents,
3467                    0,
3468                    contract.input_grouping.min(refs.len()),
3469                    aligned_len,
3470                    aligned_len,
3471                    contract,
3472                    exponents.len(),
3473                    #[cfg(target_arch = "x86_64")]
3474                    &mut jit_workspaces,
3475                    #[cfg(target_arch = "x86_64")]
3476                    usize::MAX,
3477                )
3478                .unwrap();
3479                finish_output(
3480                    resolved,
3481                    output.as_bytes_mut(),
3482                    aligned_len,
3483                    aligned_len,
3484                    exponents.len(),
3485                )
3486                .unwrap();
3487                passes.push(output.as_bytes().to_vec());
3488            }
3489            for (index, pass) in passes.iter().enumerate().skip(1) {
3490                assert_eq!(
3491                    *pass, passes[0],
3492                    "kernel {requested:?} tiling pass {index} differs from the untiled pass"
3493                );
3494            }
3495        }
3496    }
3497
3498    /// The order in which the encode feed asks for source bytes, which is what
3499    /// decides whether a hash can be driven from inside it.
3500    ///
3501    /// Within one stripe the feed walks sources in increasing index, and each
3502    /// source's bytes arrive in increasing offset across stripes — so a
3503    /// PER-SLICE digest can be carried across stripes and fused into the feed.
3504    /// A PER-FILE digest cannot unless the pass is single-stripe: with more
3505    /// than one stripe the order is stripe-major (every source's first chunk,
3506    /// then every source's second chunk), never file order. This test pins that
3507    /// distinction, because "hash from the encode feed" is only correct for the
3508    /// file MD5 while `chunk_len == slice_size`.
3509    #[test]
3510    fn the_feed_is_stripe_major_once_a_slice_needs_more_than_one_stripe() {
3511        struct Recorder<'a> {
3512            sources: &'a [&'a [u8]],
3513            reads: Vec<(usize, usize)>,
3514        }
3515        impl ForwardSourceProvider for Recorder<'_> {
3516            fn source_count(&self) -> usize {
3517                self.sources.len()
3518            }
3519            fn source_slice_len(&self, source_index: usize) -> Result<usize> {
3520                Ok(self.sources[source_index].len())
3521            }
3522            fn read_source_chunk(
3523                &mut self,
3524                source_index: usize,
3525                offset: usize,
3526                destination: &mut [u8],
3527            ) -> Result<usize> {
3528                if source_index < self.sources.len() {
3529                    self.reads.push((source_index, offset));
3530                }
3531                let source = self.sources[source_index];
3532                let start = offset.min(source.len());
3533                let take = destination.len().min(source.len() - start);
3534                destination[..take].copy_from_slice(&source[start..start + take]);
3535                Ok(take)
3536            }
3537        }
3538
3539        const SLICE: usize = 4096;
3540        let sources: Vec<Vec<u8>> = (0..3usize).map(|s| vec![s as u8 + 1; SLICE]).collect();
3541        let refs = sources.iter().map(Vec::as_slice).collect::<Vec<_>>();
3542        let encoder = ForwardEncoder::new(SLICE, vec![0, 1]).unwrap();
3543
3544        // A budget that admits the whole slice: one stripe, so every source is
3545        // delivered start to end before the next one begins — file order.
3546        let mut single = Recorder {
3547            sources: &refs,
3548            reads: Vec::new(),
3549        };
3550        let mut sink = VecRecoverySink::new(&[0, 1], SLICE);
3551        encoder
3552            .encode_to(
3553                &mut single,
3554                &ForwardEncoderOptions {
3555                    memory_limit: Some(4 * 1024 * 1024),
3556                    ..ForwardEncoderOptions::default()
3557                },
3558                &mut sink,
3559            )
3560            .unwrap();
3561        assert_eq!(
3562            single.reads,
3563            vec![(0, 0), (1, 0), (2, 0)],
3564            "a single-stripe feed must deliver each source once, whole"
3565        );
3566
3567        // A budget that forces the slice into several stripes: the same source
3568        // is now revisited at a later offset only after every other source has
3569        // been served at the earlier one.
3570        let mut split = Recorder {
3571            sources: &refs,
3572            reads: Vec::new(),
3573        };
3574        let mut sink = VecRecoverySink::new(&[0, 1], SLICE);
3575        encoder
3576            .encode_to(
3577                &mut split,
3578                &ForwardEncoderOptions {
3579                    memory_limit: Some(32 * 1024),
3580                    ..ForwardEncoderOptions::default()
3581                },
3582                &mut sink,
3583            )
3584            .unwrap();
3585        let offsets: Vec<usize> = split.reads.iter().map(|&(_, offset)| offset).collect();
3586        assert!(
3587            offsets.iter().any(|&offset| offset > 0),
3588            "the tight budget must split the slice into stripes"
3589        );
3590        assert!(
3591            split
3592                .reads
3593                .windows(2)
3594                .any(|pair| pair[0].0 > pair[1].0 && pair[1].1 > pair[0].1),
3595            "a multi-stripe feed is stripe-major: {:?}",
3596            split.reads
3597        );
3598    }
3599
3600    #[cfg(target_arch = "x86_64")]
3601    #[test]
3602    fn advertised_kernels_use_the_production_capability_resolver() {
3603        let capabilities = runtime_kernel_capabilities();
3604        let advertised = ForwardEncoder::available_kernels();
3605        assert_eq!(
3606            advertised.contains(&ForwardKernel::Folded),
3607            capabilities.folded
3608        );
3609        let encoder = ForwardEncoder::new(256, vec![0]).unwrap();
3610        for kernel in advertised {
3611            assert!(
3612                encoder.selected_kernel(kernel).is_ok(),
3613                "advertised kernel {kernel:?} cannot be selected"
3614            );
3615        }
3616    }
3617
3618    #[cfg(target_arch = "x86_64")]
3619    #[test]
3620    fn automatic_admission_keeps_the_full_kernel_ladder_ordered() {
3621        let folded_only = KernelCapabilities {
3622            folded: true,
3623            folded_wide: false,
3624            avx2_jit: false,
3625        };
3626        assert_eq!(
3627            auto_kernel_candidates(folded_only),
3628            vec![
3629                ResolvedKernel::Folded,
3630                ResolvedKernel::Simd,
3631                ResolvedKernel::Portable,
3632            ]
3633        );
3634
3635        let direct_simd_only = KernelCapabilities {
3636            folded: false,
3637            folded_wide: false,
3638            avx2_jit: false,
3639        };
3640        assert_eq!(
3641            auto_kernel_candidates(direct_simd_only),
3642            vec![ResolvedKernel::Simd, ResolvedKernel::Portable]
3643        );
3644    }
3645
3646    /// A fast-JIT AVX2 host (Zen 2 class: AVX2, no GFNI, no AVX-512) auto-
3647    /// selects the split-layout shuffle for create; the packed XOR-JIT is
3648    /// still an explicit request and still the first admission fallback.
3649    #[cfg(target_arch = "x86_64")]
3650    #[test]
3651    fn create_auto_ladder_prefers_shuffle_over_jit_on_fast_jit_hosts() {
3652        let fast_jit_avx2 = KernelCapabilities {
3653            folded: true,
3654            folded_wide: false,
3655            avx2_jit: true,
3656        };
3657        assert_eq!(
3658            resolve_kernel_with_capabilities(ForwardKernel::Auto, fast_jit_avx2).unwrap(),
3659            ResolvedKernel::Folded
3660        );
3661        assert_eq!(
3662            resolve_kernel_with_capabilities(ForwardKernel::XorJitAvx2, fast_jit_avx2).unwrap(),
3663            ResolvedKernel::XorJitAvx2
3664        );
3665        assert_eq!(
3666            auto_kernel_candidates(fast_jit_avx2),
3667            vec![
3668                ResolvedKernel::Folded,
3669                ResolvedKernel::XorJitAvx2,
3670                ResolvedKernel::Simd,
3671                ResolvedKernel::Portable,
3672            ]
3673        );
3674        // Without the folded family (no AVX2 or SSSE3 altmap at all) the JIT
3675        // gate cannot be open either; the ladder degrades to the direct SIMD.
3676        let jit_without_folded = KernelCapabilities {
3677            folded: false,
3678            folded_wide: false,
3679            avx2_jit: true,
3680        };
3681        assert_eq!(
3682            resolve_kernel_with_capabilities(ForwardKernel::Auto, jit_without_folded).unwrap(),
3683            ResolvedKernel::XorJitAvx2
3684        );
3685    }
3686
3687    #[cfg(target_arch = "x86_64")]
3688    #[test]
3689    fn production_admission_can_fall_back_from_folded_to_simd() {
3690        let capabilities = KernelCapabilities {
3691            folded: true,
3692            folded_wide: false,
3693            avx2_jit: false,
3694        };
3695        let raw = resolve_kernel_with_capabilities(ForwardKernel::Auto, capabilities).unwrap();
3696        assert_eq!(raw, ResolvedKernel::Folded);
3697
3698        let slice_size = 60;
3699        let source_count = 19;
3700        let first_exponent = 0_u32;
3701        let recovery_count = u32::from(u16::MAX);
3702        assert!(first_exponent + recovery_count < u32::from(u16::MAX) + 1);
3703        let output_count = recovery_count as usize;
3704        let minimum_memory_limit = |requested| {
3705            let (_, full_plan) = select_kernel_for_memory_with_capabilities(
3706                slice_size,
3707                output_count,
3708                source_count,
3709                usize::MAX,
3710                requested,
3711                capabilities,
3712            )
3713            .unwrap();
3714            let mut lower = 0;
3715            let mut upper = full_plan.memory_bytes;
3716            while lower < upper {
3717                let middle = lower + (upper - lower) / 2;
3718                if select_kernel_for_memory_with_capabilities(
3719                    slice_size,
3720                    output_count,
3721                    source_count,
3722                    middle,
3723                    requested,
3724                    capabilities,
3725                )
3726                .is_ok()
3727                {
3728                    upper = middle;
3729                } else {
3730                    lower = middle + 1;
3731                }
3732            }
3733            assert!(
3734                select_kernel_for_memory_with_capabilities(
3735                    slice_size,
3736                    output_count,
3737                    source_count,
3738                    lower,
3739                    requested,
3740                    capabilities,
3741                )
3742                .is_ok()
3743            );
3744            if lower > 0 {
3745                assert!(
3746                    select_kernel_for_memory_with_capabilities(
3747                        slice_size,
3748                        output_count,
3749                        source_count,
3750                        lower - 1,
3751                        requested,
3752                        capabilities,
3753                    )
3754                    .is_err()
3755                );
3756            }
3757            lower
3758        };
3759        let folded_minimum = minimum_memory_limit(ForwardKernel::Folded);
3760        let simd_minimum = minimum_memory_limit(ForwardKernel::Simd);
3761        assert!(
3762            folded_minimum > simd_minimum,
3763            "folded minimum {folded_minimum} is not above simd minimum {simd_minimum}"
3764        );
3765        let memory_limit = simd_minimum;
3766        assert!(
3767            select_kernel_for_memory_with_capabilities(
3768                slice_size,
3769                output_count,
3770                source_count,
3771                memory_limit,
3772                ForwardKernel::Folded,
3773                capabilities,
3774            )
3775            .is_err()
3776        );
3777        let (admitted, _) = select_kernel_for_memory_with_capabilities(
3778            slice_size,
3779            output_count,
3780            source_count,
3781            memory_limit,
3782            ForwardKernel::Auto,
3783            capabilities,
3784        )
3785        .unwrap();
3786        assert_eq!(admitted, ResolvedKernel::Simd);
3787    }
3788
3789    #[test]
3790    fn final_stripe_is_not_padded_in_sink() {
3791        struct Sink {
3792            chunks: Vec<(usize, RecoveryExponent, u64, Vec<u8>)>,
3793        }
3794        impl ForwardRecoverySink for Sink {
3795            fn write_recovery_chunk(
3796                &mut self,
3797                output_index: usize,
3798                exponent: RecoveryExponent,
3799                offset: u64,
3800                data: &[u8],
3801            ) -> Result<()> {
3802                self.chunks
3803                    .push((output_index, exponent, offset, data.to_vec()));
3804                Ok(())
3805            }
3806        }
3807
3808        let sources = test_sources();
3809        let refs = sources.iter().map(Vec::as_slice).collect::<Vec<_>>();
3810        let encoder =
3811            ForwardEncoder::new(260, vec![4, 9]).expect("slice size is a valid PAR2 size");
3812        let mut sink = Sink { chunks: Vec::new() };
3813        encoder
3814            .encode_slices_to(
3815                &refs,
3816                &ForwardEncoderOptions {
3817                    memory_limit: Some(8_800),
3818                    kernel: ForwardKernel::Portable,
3819                    ..ForwardEncoderOptions::default()
3820                },
3821                &mut sink,
3822            )
3823            .unwrap();
3824        assert!(sink.chunks.iter().all(|(_, _, _, data)| data.len() <= 260));
3825        // The stripe length is whatever the 8,800-byte budget admits for the
3826        // family's staging shape (256 with twelve lanes, 188 with sixteen);
3827        // what must hold regardless is that the final stripe carries exactly
3828        // the slice remainder and nothing after it.
3829        let stripe = sink.chunks[0].3.len();
3830        assert!(
3831            (2..260).contains(&stripe),
3832            "the memory limit must force a multi-stripe plan, got stripe {stripe}"
3833        );
3834        let stripes = 260usize.div_ceil(stripe);
3835        assert_eq!(sink.chunks.len(), 2 * stripes);
3836        let last = sink.chunks.last().unwrap();
3837        assert_eq!(last.2 as usize, (stripes - 1) * stripe);
3838        assert_eq!(last.3.len(), 260 - (stripes - 1) * stripe);
3839    }
3840
3841    #[test]
3842    fn tight_memory_preserves_recovery_bytes_for_every_available_kernel() {
3843        let slice_size = 1028usize;
3844        let source_count = 19;
3845        let output_count = 3;
3846        let sources = (0..source_count)
3847            .map(|source| {
3848                (0..slice_size)
3849                    .map(|index| (index.wrapping_mul(17) ^ (source * 29)) as u8)
3850                    .collect()
3851            })
3852            .collect::<Vec<Vec<u8>>>();
3853        let refs = sources.iter().map(Vec::as_slice).collect::<Vec<_>>();
3854        let exponents = vec![4, 9, 17];
3855        assert_eq!(refs.len(), source_count);
3856        assert_eq!(exponents.len(), output_count);
3857        let encoder = ForwardEncoder::new(slice_size, exponents).unwrap();
3858        let (_, reference_plan) = select_kernel_for_memory(
3859            slice_size,
3860            output_count,
3861            source_count,
3862            usize::MAX,
3863            ForwardKernel::Portable,
3864        )
3865        .unwrap();
3866        assert_eq!(reference_plan.chunk_len, slice_size);
3867        let reference = encoder
3868            .encode(
3869                &refs,
3870                &ForwardEncoderOptions {
3871                    memory_limit: Some(reference_plan.memory_bytes),
3872                    kernel: ForwardKernel::Portable,
3873                    ..ForwardEncoderOptions::default()
3874                },
3875            )
3876            .unwrap();
3877
3878        for kernel in ForwardEncoder::available_kernels() {
3879            let (_, full_plan) = select_kernel_for_memory(
3880                slice_size,
3881                output_count,
3882                source_count,
3883                usize::MAX,
3884                kernel,
3885            )
3886            .unwrap();
3887            let (tight_limit, tight_plan) = if full_plan.chunk_len < slice_size {
3888                (full_plan.memory_bytes, full_plan)
3889            } else {
3890                let mut memory_limit = full_plan.memory_bytes;
3891                loop {
3892                    memory_limit = memory_limit
3893                        .checked_sub(1)
3894                        .expect("a full-stripe plan has a smaller admitted plan");
3895                    match select_kernel_for_memory(
3896                        slice_size,
3897                        output_count,
3898                        source_count,
3899                        memory_limit,
3900                        kernel,
3901                    ) {
3902                        Ok((_, plan))
3903                            if plan.chunk_len < slice_size
3904                                && !slice_size.is_multiple_of(plan.chunk_len) =>
3905                        {
3906                            break (memory_limit, plan);
3907                        }
3908                        Ok(_) | Err(_) => {}
3909                    }
3910                }
3911            };
3912            assert!(
3913                tight_plan.chunk_len < slice_size,
3914                "kernel {kernel:?} retained a full-size stripe"
3915            );
3916            let stripe_count = slice_size.div_ceil(tight_plan.chunk_len);
3917            assert!(stripe_count > 1, "kernel {kernel:?} used one stripe");
3918            let final_len = slice_size % tight_plan.chunk_len;
3919            assert!(
3920                final_len > 0 && final_len < tight_plan.chunk_len,
3921                "kernel {kernel:?} did not produce a short final stripe"
3922            );
3923
3924            let actual = encoder
3925                .encode(
3926                    &refs,
3927                    &ForwardEncoderOptions {
3928                        memory_limit: Some(tight_limit),
3929                        kernel,
3930                        ..ForwardEncoderOptions::default()
3931                    },
3932                )
3933                .unwrap();
3934            assert_eq!(actual, reference, "kernel {kernel:?} differs from portable");
3935        }
3936    }
3937
3938    #[test]
3939    fn every_available_kernel_streams_contiguous_unpadded_chunks() {
3940        struct Sink {
3941            chunks: Vec<(usize, RecoveryExponent, u64, Vec<u8>)>,
3942        }
3943        impl ForwardRecoverySink for Sink {
3944            fn write_recovery_chunk(
3945                &mut self,
3946                output_index: usize,
3947                exponent: RecoveryExponent,
3948                offset: u64,
3949                data: &[u8],
3950            ) -> Result<()> {
3951                self.chunks
3952                    .push((output_index, exponent, offset, data.to_vec()));
3953                Ok(())
3954            }
3955        }
3956
3957        let sources = test_sources();
3958        let refs = sources.iter().map(Vec::as_slice).collect::<Vec<_>>();
3959        let exponents = vec![4, 9];
3960        let encoder = ForwardEncoder::new(260, exponents.clone()).unwrap();
3961        let options = |kernel| ForwardEncoderOptions {
3962            memory_limit: Some(1024 * 1024),
3963            kernel,
3964            ..ForwardEncoderOptions::default()
3965        };
3966        let reference = encoder
3967            .encode(&refs, &options(ForwardKernel::Portable))
3968            .unwrap();
3969
3970        for kernel in ForwardEncoder::available_kernels() {
3971            let actual = encoder.encode(&refs, &options(kernel)).unwrap();
3972            assert_eq!(actual, reference, "kernel {kernel:?} differs from portable");
3973
3974            let mut sink = Sink { chunks: Vec::new() };
3975            encoder
3976                .encode_slices_to(&refs, &options(kernel), &mut sink)
3977                .unwrap();
3978            let mut next_offset = vec![0u64; exponents.len()];
3979            for (position, (output_index, exponent, offset, data)) in sink.chunks.iter().enumerate()
3980            {
3981                assert_eq!(*output_index, position % exponents.len());
3982                assert_eq!(*exponent, exponents[*output_index]);
3983                assert_eq!(*offset, next_offset[*output_index]);
3984                assert!(*offset + data.len() as u64 <= encoder.slice_size() as u64);
3985                next_offset[*output_index] += data.len() as u64;
3986            }
3987            assert!(next_offset.iter().all(|&offset| offset == 260));
3988        }
3989    }
3990
3991    #[test]
3992    fn insufficient_memory_rejects_without_zero_length_stripes() {
3993        let result = BufferPlan::new_with_reserved(
3994            260,
3995            1,
3996            KernelContract {
3997                stride: 32,
3998                input_grouping: DEFAULT_INPUT_GROUPING,
3999                tile_bytes: TABLE_TILE_BYTES,
4000                skewed_lanes: true,
4001                interleave_lanes: 1,
4002            },
4003            1,
4004            0,
4005            0,
4006            0,
4007        );
4008        assert!(matches!(
4009            result,
4010            Err(Par2Error::ResourceLimitExceeded { .. })
4011        ));
4012    }
4013
4014    #[test]
4015    fn factor_workspace_does_not_scale_with_recovery_rows() {
4016        let one = estimate_forward_memory(
4017            4,
4018            MAX_TOTAL_INPUT_SLICES,
4019            1,
4020            3 * 1024 * 1024,
4021            ForwardKernel::Portable,
4022        )
4023        .unwrap();
4024        let many = estimate_forward_memory(
4025            4,
4026            MAX_TOTAL_INPUT_SLICES,
4027            MAX_TOTAL_INPUT_SLICES,
4028            3 * 1024 * 1024,
4029            ForwardKernel::Portable,
4030        )
4031        .unwrap();
4032        assert_eq!(one.factor_workspace_bytes, many.factor_workspace_bytes);
4033        assert!(one.factor_workspace_bytes < 128 * 1024);
4034        assert!(many.processing_peak_bytes <= 3 * 1024 * 1024);
4035    }
4036
4037    #[test]
4038    fn low_memory_rejects_before_large_output_allocation() {
4039        let result = estimate_forward_memory(
4040            4096,
4041            MAX_TOTAL_INPUT_SLICES,
4042            MAX_TOTAL_INPUT_SLICES,
4043            64 * 1024,
4044            ForwardKernel::Portable,
4045        );
4046        assert!(matches!(
4047            result,
4048            Err(Par2Error::ResourceLimitExceeded { .. })
4049        ));
4050    }
4051
4052    #[test]
4053    fn staging_zero_pads_an_odd_final_byte_as_a_low_byte_word() {
4054        let source = [0x11, 0x22, 0x33];
4055        let refs = [source.as_slice()];
4056        let mut provider = InMemorySourceProvider { sources: &refs };
4057        let mut staging = AlignedBuffer::new(DEFAULT_INPUT_GROUPING * 4);
4058        let mut transfer = AlignedBuffer::new(DEFAULT_INPUT_GROUPING * 64);
4059        fill_staging(
4060            ResolvedKernel::Portable,
4061            &mut staging,
4062            &mut transfer,
4063            &mut provider,
4064            0,
4065            0,
4066            3,
4067            4,
4068            KernelContract {
4069                stride: 2,
4070                input_grouping: DEFAULT_INPUT_GROUPING,
4071                tile_bytes: TABLE_TILE_BYTES,
4072                skewed_lanes: true,
4073                interleave_lanes: 1,
4074            },
4075            &mut [0usize; MAX_INPUT_GROUPING],
4076        )
4077        .unwrap();
4078        assert_eq!(&staging.as_bytes()[..4], &[0x11, 0x22, 0x33, 0]);
4079    }
4080
4081    #[test]
4082    fn cancellation_is_observed_before_allocation() {
4083        let sources = test_sources();
4084        let refs = sources.iter().map(Vec::as_slice).collect::<Vec<_>>();
4085        let token = CancellationToken::new();
4086        token.cancel();
4087        let encoder = ForwardEncoder::new(256, vec![0]).unwrap();
4088        let error = encoder
4089            .encode(
4090                &refs,
4091                &ForwardEncoderOptions {
4092                    cancel: Some(token),
4093                    ..ForwardEncoderOptions::default()
4094                },
4095            )
4096            .unwrap_err();
4097        assert!(matches!(error, Par2Error::Cancelled));
4098    }
4099
4100    #[test]
4101    fn payload_matches_vandermonde_definition() {
4102        let sources = test_sources();
4103        let refs = sources.iter().map(Vec::as_slice).collect::<Vec<_>>();
4104        let exponents = [0, 31];
4105        let encoder = ForwardEncoder::new(256, exponents.to_vec()).unwrap();
4106        let actual = encoder
4107            .encode(
4108                &refs,
4109                &ForwardEncoderOptions {
4110                    kernel: ForwardKernel::Portable,
4111                    ..ForwardEncoderOptions::default()
4112                },
4113            )
4114            .unwrap();
4115        let constants = gf::input_slice_constants(refs.len());
4116
4117        for (output, &exponent) in exponents.iter().enumerate() {
4118            let mut expected = vec![0u8; 256];
4119            for (source_index, source) in refs.iter().enumerate() {
4120                let factor = gf::pow(constants[source_index], exponent);
4121                for word in 0..128 {
4122                    let offset = word * 2;
4123                    let source_word = if offset < source.len() {
4124                        u16::from_le_bytes([
4125                            source[offset],
4126                            source.get(offset + 1).map_or(0, |byte| *byte),
4127                        ])
4128                    } else {
4129                        0
4130                    };
4131                    let output_word = u16::from_le_bytes([expected[offset], expected[offset + 1]])
4132                        ^ gf::mul(source_word, factor);
4133                    expected[offset..offset + 2].copy_from_slice(&output_word.to_le_bytes());
4134                }
4135            }
4136            assert_eq!(actual[output].data, expected);
4137        }
4138    }
4139
4140    /// The stripe skew is a fixed rule of the stripe length: it moves the
4141    /// stride to 1 KiB modulo 4 KiB, capped at 1/8 of the stripe, and is zero
4142    /// when the stripe already sits at that residue.
4143    #[test]
4144    fn stripe_skew_follows_the_stripe_length() {
4145        assert_eq!(stripe_skew_bytes(2), 0);
4146        assert_eq!(stripe_skew_bytes(256), 0);
4147        assert_eq!(stripe_skew_bytes(1023), 0);
4148        assert_eq!(stripe_skew_bytes(1024), 0, "already 1 KiB mod 4 KiB");
4149        assert_eq!(stripe_skew_bytes(2048), 256, "wants 3 KiB, capped at 1/8");
4150        assert_eq!(stripe_skew_bytes(4096), 512, "wants 1 KiB, capped at 1/8");
4151        assert_eq!(stripe_skew_bytes(40_960), 1024);
4152        assert_eq!(stripe_skew_bytes(65_536), 1024);
4153        assert_eq!(stripe_skew_bytes(66_560), 0, "already 1 KiB mod 4 KiB");
4154        assert_eq!(
4155            stripe_skew_bytes(67_584),
4156            3072,
4157            "2 KiB residue moves to 1 KiB"
4158        );
4159        assert_eq!(stripe_skew_bytes(1 << 20), 1024);
4160        // Uncapped cases land exactly on the target residue.
4161        for aligned_len in [8192usize, 40_960, 65_536, 67_584, 1 << 20] {
4162            let stride = aligned_len + stripe_skew_bytes(aligned_len);
4163            assert_eq!(stride % 4096, 1024, "stride residue for {aligned_len}");
4164        }
4165        // The plan carries the skew into both strides at the shape the
4166        // benchmark corpus uses (64 KiB slices, 12-lane staging).
4167        let contract = KernelContract {
4168            stride: 2,
4169            input_grouping: DEFAULT_INPUT_GROUPING,
4170            tile_bytes: TABLE_TILE_BYTES,
4171            skewed_lanes: true,
4172            interleave_lanes: 1,
4173        };
4174        let plan =
4175            BufferPlan::new_with_reserved(65_536, 820, contract, usize::MAX, 0, 0, 0).unwrap();
4176        assert_eq!(plan.aligned_chunk_len, 65_536);
4177        assert_eq!(plan.row_stride, 65_536 + 1024);
4178        assert_eq!(plan.staging_bytes, DEFAULT_INPUT_GROUPING * (65_536 + 1024));
4179        assert_eq!(plan.output_bytes, 820 * (65_536 + 1024));
4180        assert_eq!(lane_stride(contract, 65_536), 65_536 + 1024);
4181        assert_eq!(
4182            lane_stride(
4183                KernelContract {
4184                    skewed_lanes: false,
4185                    ..contract
4186                },
4187                65_536
4188            ),
4189            65_536
4190        );
4191    }
4192
4193    /// The slice-per-source families batch by kernel shape: sixteen on the
4194    /// aarch64 CLMUL family (two full eight-source passes), twelve elsewhere;
4195    /// the folded and packed XOR-JIT families are structurally twelve.
4196    #[test]
4197    fn input_grouping_follows_the_kernel_family() {
4198        let simd = KernelContract::for_kernel(ResolvedKernel::Simd);
4199        let portable = KernelContract::for_kernel(ResolvedKernel::Portable);
4200        assert_eq!(simd.input_grouping, portable.input_grouping);
4201        assert!((1..=MAX_INPUT_GROUPING).contains(&simd.input_grouping));
4202        if std::env::var_os("WEAVER_PAR2_CREATE_GROUPING").is_none() {
4203            #[cfg(target_arch = "aarch64")]
4204            assert_eq!(simd.input_grouping, CLMUL_INPUT_GROUPING);
4205            #[cfg(not(target_arch = "aarch64"))]
4206            assert_eq!(simd.input_grouping, DEFAULT_INPUT_GROUPING);
4207        }
4208        #[cfg(target_arch = "x86_64")]
4209        for kernel in ForwardEncoder::available_kernels() {
4210            let resolved =
4211                resolve_kernel_with_capabilities(kernel, runtime_kernel_capabilities()).unwrap();
4212            if matches!(
4213                resolved,
4214                ResolvedKernel::Folded | ResolvedKernel::XorJitAvx2
4215            ) {
4216                assert_eq!(
4217                    KernelContract::for_kernel(resolved).input_grouping,
4218                    DEFAULT_INPUT_GROUPING
4219                );
4220            }
4221        }
4222    }
4223
4224    /// With the skew live (a 4 KiB stripe skews lanes and rows by 512 bytes),
4225    /// every runtime kernel must still produce exactly the Vandermonde
4226    /// definition — the layout moves bytes, never arithmetic. Sources are
4227    /// deliberately of unequal lengths so lane tails and the zero padding sit
4228    /// in the skewed positions too.
4229    #[test]
4230    fn skewed_stripe_layout_matches_vandermonde_definition_on_every_kernel() {
4231        const SLICE: usize = 4096;
4232        assert_eq!(stripe_skew_bytes(SLICE), 512, "the skew must be live here");
4233        let sources: Vec<Vec<u8>> = (0..27usize)
4234            .map(|source| {
4235                (0..(SLICE - source * 97))
4236                    .map(|index| (index.wrapping_mul(31) ^ (source * 53) ^ (index >> 7)) as u8)
4237                    .collect()
4238            })
4239            .collect();
4240        let refs = sources.iter().map(Vec::as_slice).collect::<Vec<_>>();
4241        let exponents: [RecoveryExponent; 4] = [0, 1, 31, 100];
4242        let constants = gf::input_slice_constants(refs.len());
4243        let mut expected = Vec::new();
4244        for &exponent in &exponents {
4245            let mut block = vec![0u8; SLICE];
4246            for (source_index, source) in refs.iter().enumerate() {
4247                let factor = gf::pow(constants[source_index], exponent);
4248                for word in 0..SLICE / 2 {
4249                    let offset = word * 2;
4250                    let source_word = if offset < source.len() {
4251                        u16::from_le_bytes([
4252                            source[offset],
4253                            source.get(offset + 1).map_or(0, |byte| *byte),
4254                        ])
4255                    } else {
4256                        0
4257                    };
4258                    let output_word = u16::from_le_bytes([block[offset], block[offset + 1]])
4259                        ^ gf::mul(source_word, factor);
4260                    block[offset..offset + 2].copy_from_slice(&output_word.to_le_bytes());
4261                }
4262            }
4263            expected.push(block);
4264        }
4265        for kernel in ForwardEncoder::available_kernels() {
4266            let encoder = ForwardEncoder::new(SLICE, exponents.to_vec()).unwrap();
4267            let actual = encoder
4268                .encode(
4269                    &refs,
4270                    &ForwardEncoderOptions {
4271                        kernel,
4272                        ..ForwardEncoderOptions::default()
4273                    },
4274                )
4275                .unwrap();
4276            for (output, block) in expected.iter().enumerate() {
4277                assert_eq!(
4278                    &actual[output].data, block,
4279                    "kernel {kernel:?} output {output} diverged from the definition"
4280                );
4281            }
4282        }
4283    }
4284
4285    /// The interleaved layout must place every lane inside the area the plan
4286    /// reserves, and must reduce to the lane-major addresses at width 1 — the
4287    /// two properties that let `BufferPlan` stay untouched by the interleave.
4288    #[test]
4289    fn staging_layout_fits_the_planned_area_at_every_width() {
4290        const BLOCK: usize = gf_simd::INPUT_BATCH_BLOCK_BYTES;
4291        for aligned_len in [BLOCK, 4096usize, 8192, 65_536] {
4292            for grouping in [1usize, 4, 12, 16] {
4293                let base = KernelContract {
4294                    stride: BLOCK,
4295                    input_grouping: grouping,
4296                    tile_bytes: TABLE_TILE_BYTES,
4297                    skewed_lanes: true,
4298                    interleave_lanes: 1,
4299                };
4300                let stride = lane_stride(base, aligned_len);
4301                let planned = grouping * stride;
4302                for interleave in [1usize, 2, 4, 8, 16] {
4303                    let contract = KernelContract {
4304                        interleave_lanes: interleave,
4305                        ..base
4306                    };
4307                    let layout = StagingLayout::new(contract, aligned_len, stride);
4308                    let total = layout.total_bytes().expect("layout fits usize");
4309                    assert!(
4310                        total <= planned,
4311                        "layout {interleave}x{grouping} at {aligned_len} wants {total} of {planned}"
4312                    );
4313                    // Widths sum to the grouping, so no lane is dropped and no
4314                    // lane is counted twice.
4315                    let widths: usize = (0..layout.group_count())
4316                        .map(|group| layout.group_width(group))
4317                        .sum();
4318                    assert_eq!(widths, grouping, "every lane belongs to exactly one group");
4319                    // Every group's last tile stays inside the layout.
4320                    for group in 0..layout.group_count() {
4321                        let (_, end) = layout.group_tile(group, aligned_len - BLOCK, BLOCK);
4322                        assert!(end <= total, "group {group} tile runs past the layout");
4323                    }
4324                }
4325                // Width 1 is the pre-interleave layout, byte for byte.
4326                let lane_major = StagingLayout::new(base, aligned_len, stride);
4327                for lane in 0..grouping {
4328                    assert_eq!(lane_major.group_base(lane), lane * stride);
4329                }
4330            }
4331        }
4332    }
4333
4334    /// A staging area shorter than the batch's layout is refused up front, as a
4335    /// resource error, rather than being discovered as a slice panic partway
4336    /// through the fill. One check covers every lane of every family.
4337    #[test]
4338    fn short_staging_is_refused_before_the_fill() {
4339        const BLOCK: usize = gf_simd::INPUT_BATCH_BLOCK_BYTES;
4340        let source = vec![0xA5u8; 512];
4341        let refs = [source.as_slice()];
4342        for interleave in [1usize, 8] {
4343            let contract = KernelContract {
4344                stride: BLOCK,
4345                input_grouping: 8,
4346                tile_bytes: TABLE_TILE_BYTES,
4347                skewed_lanes: true,
4348                interleave_lanes: interleave,
4349            };
4350            let stride = lane_stride(contract, 512);
4351            let needed = StagingLayout::new(contract, 512, stride)
4352                .total_bytes()
4353                .unwrap();
4354            let mut provider = InMemorySourceProvider { sources: &refs };
4355            let mut staging = AlignedBuffer::new(needed - 1);
4356            // Sized for the whole batch (one slot per lane), so the refusal
4357            // exercised here is the staging-layout check, not the transfer one.
4358            let mut transfer =
4359                AlignedBuffer::new(contract.input_grouping * transfer_slot_stride(512).unwrap());
4360            let mut slice_lens = [0usize; MAX_INPUT_GROUPING];
4361            let result = fill_staging(
4362                ResolvedKernel::Simd,
4363                &mut staging,
4364                &mut transfer,
4365                &mut provider,
4366                0,
4367                0,
4368                512,
4369                512,
4370                contract,
4371                &mut slice_lens,
4372            );
4373            assert!(
4374                matches!(result, Err(Par2Error::ResourceLimitExceeded { .. })),
4375                "interleave {interleave} accepted a short staging area"
4376            );
4377        }
4378    }
4379
4380    /// The block-interleaved staging layout is a pure relocation of the same
4381    /// bytes: for every interleave width, every live-input count and every
4382    /// tile, the accumulated recovery bytes must equal the lane-major layout's
4383    /// — and must equal the word-wise `Portable` kernel's, so two broken
4384    /// layouts cannot agree their way to a pass.
4385    ///
4386    /// The live counts straddle the interleave boundary on purpose: eleven live
4387    /// inputs at width eight is one full group and one partly-live group, and
4388    /// three is the width at which dispatch leaves the CLMUL pass for the VTBL
4389    /// kernel, which reads the same layout.
4390    #[test]
4391    fn interleaved_staging_matches_lane_major_and_the_word_wise_kernel() {
4392        const BLOCK: usize = gf_simd::INPUT_BATCH_BLOCK_BYTES;
4393        // A whole number of blocks, not a whole number of tiles, with sources
4394        // shorter than the stripe so the zero padding is live.
4395        const SLICE: usize = 8 * 1024 + 96;
4396        let sources: Vec<Vec<u8>> = (0..MAX_INPUT_GROUPING)
4397            .map(|source| {
4398                (0..(SLICE - source * 37))
4399                    .map(|index| (index.wrapping_mul(31) ^ (source * 53) ^ (index >> 5)) as u8)
4400                    .collect()
4401            })
4402            .collect();
4403        let refs = sources.iter().map(Vec::as_slice).collect::<Vec<_>>();
4404        let exponents: Vec<RecoveryExponent> = vec![0, 1, 2, 31, 100];
4405        let simd = KernelContract::for_kernel(ResolvedKernel::Simd);
4406        let aligned_len = round_up(SLICE, BLOCK).unwrap();
4407
4408        let run = |kernel: ResolvedKernel, contract: KernelContract, live: usize| -> Vec<u8> {
4409            let mut provider = InMemorySourceProvider { sources: &refs };
4410            let mut staging =
4411                AlignedBuffer::new(contract.input_grouping * lane_stride(contract, aligned_len));
4412            let mut transfer = AlignedBuffer::new(
4413                contract.input_grouping * transfer_slot_stride(aligned_len).unwrap(),
4414            );
4415            let mut slice_lens = [0usize; MAX_INPUT_GROUPING];
4416            fill_staging(
4417                kernel,
4418                &mut staging,
4419                &mut transfer,
4420                &mut provider,
4421                0,
4422                0,
4423                SLICE,
4424                aligned_len,
4425                contract,
4426                &mut slice_lens,
4427            )
4428            .unwrap();
4429            let factors = FactorSource::new(refs.len());
4430            let mut output = AlignedBuffer::new(exponents.len() * aligned_len);
4431            #[cfg(target_arch = "x86_64")]
4432            let mut jit_workspaces: Vec<
4433                reedsolomon_rs::xor_jit::packed::PackedJitWorkspace,
4434            > = vec![Default::default()];
4435            accumulate_batch(
4436                kernel,
4437                output.as_bytes_mut(),
4438                &staging,
4439                &factors,
4440                &exponents,
4441                0,
4442                live,
4443                aligned_len,
4444                aligned_len,
4445                contract,
4446                exponents.len(),
4447                #[cfg(target_arch = "x86_64")]
4448                &mut jit_workspaces,
4449                #[cfg(target_arch = "x86_64")]
4450                usize::MAX,
4451            )
4452            .unwrap();
4453            output.as_bytes().to_vec()
4454        };
4455
4456        // Groupings that are and are not multiples of the interleave: twelve
4457        // inputs eight-wide is a group of eight and a group of four, which is
4458        // the `WEAVER_PAR2_CREATE_GROUPING=12` pin's shape and the only one
4459        // where a group's width differs from the nominal interleave.
4460        for grouping in [12usize, 16, MAX_INPUT_GROUPING] {
4461            let portable = KernelContract {
4462                input_grouping: grouping,
4463                ..KernelContract::for_kernel(ResolvedKernel::Portable)
4464            };
4465            for live in [1usize, 3, 8, 11, grouping] {
4466                let live = live.min(grouping).min(refs.len());
4467                let definition = run(ResolvedKernel::Portable, portable, live);
4468                for tile_bytes in [UNTILED, 8192usize, 2048] {
4469                    let mut lane_major: Option<Vec<u8>> = None;
4470                    for interleave in [1usize, 2, 4, 8, 16] {
4471                        let contract = KernelContract {
4472                            stride: BLOCK,
4473                            tile_bytes,
4474                            input_grouping: grouping,
4475                            interleave_lanes: interleave,
4476                            ..simd
4477                        };
4478                        let got = run(ResolvedKernel::Simd, contract, live);
4479                        let case = format!(
4480                            "grouping={grouping} interleave={interleave} \
4481                             tile={tile_bytes} live={live}"
4482                        );
4483                        assert_eq!(
4484                            got, definition,
4485                            "simd {case} diverged from the word-wise kernel"
4486                        );
4487                        match &lane_major {
4488                            None => lane_major = Some(got),
4489                            Some(expected) => assert_eq!(
4490                                &got, expected,
4491                                "simd {case} diverged from the lane-major layout"
4492                            ),
4493                        }
4494                    }
4495                }
4496            }
4497        }
4498    }
4499
4500    #[test]
4501    fn zero_input_produces_zero_recovery_blocks() {
4502        let encoder = ForwardEncoder::new(256, vec![0, 5]).unwrap();
4503        let blocks = encoder
4504            .encode(&[], &ForwardEncoderOptions::default())
4505            .unwrap();
4506        assert_eq!(blocks.len(), 2);
4507        assert!(
4508            blocks
4509                .iter()
4510                .all(|block| block.data.iter().all(|&byte| byte == 0))
4511        );
4512    }
4513}