Skip to main content

binius_hash_prover/blake3/
portable.rs

1// Copyright 2026 The Binius Developers
2
3//! Experimental portable, auto-vectorized Blake3 multi-lane kernel.
4//!
5//! Two batched entry points share one block-compression core:
6//! - Leaf hashing, for any message up to one 1024-byte chunk.
7//! - Two-to-one inner-node compression, `blake3(left || right)` over a 64-byte pair.
8//!
9//! An alternative to driving the `blake3` crate's hand-written SIMD kernel.
10//! The bet: LLVM auto-vectorizes plain lane loops into whatever the target has.
11//!
12//! - Each of the 16 compression-state words is held as `[u32; N]`, one lane per message.
13//! - Every step is a fixed-width `0..N` loop of plain scalar `u32` arithmetic.
14//! - No intrinsics, no `unsafe`, no per-target code inside the loops.
15//!
16//! Lanes the vectorizer is expected to fill, per target:
17//! - NEON (128-bit) on ARM64 -> 4 lanes per vector.
18//! - AVX2 / AVX-512 on x86 -> 8 / 16 lanes per vector.
19//! - SVE2 on capable ARM64 -> width-agnostic vectors.
20//!
21//! Where a hand-written kernel beats the vectorizer, the loops become the fallback:
22//! - `load_block_words` hands the message transpose to the parent's `avx512` or `neon` module.
23//! - `compress_block` hands the block compression to the parent's `neon` module.
24//!
25//! Each hands over only on the lane counts that kernel claims; every other count runs the loops.
26//!
27//! Output is bit-identical to `blake3::hash`, pinned to the reference in tests.
28//! Scope: any message up to one 1024-byte chunk, including sub-block and partial-block leaves.
29
30use std::{array, mem::MaybeUninit};
31
32use binius_hash::Blake3Compression;
33use binius_utils::{
34	FixedSizeSerializeBytes, SerializeBytes,
35	rayon::{
36		iter::{IndexedParallelIterator, ParallelIterator},
37		slice::{ParallelSlice, ParallelSliceMut},
38	},
39};
40use blake3::{BLOCK_LEN, CHUNK_LEN, OUT_LEN};
41use digest::Output;
42
43#[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))]
44use super::avx512;
45#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
46use super::neon;
47use super::{CHUNK_END, CHUNK_START, IV, MSG_SCHEDULE, ROOT};
48use crate::{
49	parallel_compression::ParallelPseudoCompression,
50	parallel_digest::{
51		MultiDigest, ParallelDigest, ParallelDigestAdapter, ParallelMultidigestImpl,
52	},
53};
54
55/// Applies one Blake3 quarter-round across all `N` lanes.
56///
57/// The state words at positions `a, b, c, d` are mixed with two message words per lane.
58/// Every line is an independent `0..N` map, which is what the vectorizer turns into SIMD.
59#[inline(always)]
60fn quarter_round<const N: usize>(
61	v: &mut [[u32; N]; 16],
62	a: usize,
63	b: usize,
64	c: usize,
65	d: usize,
66	mx: &[u32; N],
67	my: &[u32; N],
68) {
69	// One lane per iteration; lanes are independent, so the loop vectorizes.
70	for i in 0..N {
71		v[a][i] = v[a][i].wrapping_add(v[b][i]).wrapping_add(mx[i]);
72		v[d][i] = (v[d][i] ^ v[a][i]).rotate_right(16);
73		v[c][i] = v[c][i].wrapping_add(v[d][i]);
74		v[b][i] = (v[b][i] ^ v[c][i]).rotate_right(12);
75		v[a][i] = v[a][i].wrapping_add(v[b][i]).wrapping_add(my[i]);
76		v[d][i] = (v[d][i] ^ v[a][i]).rotate_right(8);
77		v[c][i] = v[c][i].wrapping_add(v[d][i]);
78		v[b][i] = (v[b][i] ^ v[c][i]).rotate_right(7);
79	}
80}
81
82/// Applies round `R`: four column mixes, then four diagonal mixes.
83///
84/// The two message words each quarter-round folds in come from row `R` of the schedule table.
85#[inline(always)]
86fn round<const R: usize, const N: usize>(v: &mut [[u32; N]; 16], m: &[[u32; N]; 16]) {
87	// The 16 message words this round reads, in slot order.
88	let s = MSG_SCHEDULE[R];
89
90	// Columns.
91	quarter_round(v, 0, 4, 8, 12, &m[s[0]], &m[s[1]]);
92	quarter_round(v, 1, 5, 9, 13, &m[s[2]], &m[s[3]]);
93	quarter_round(v, 2, 6, 10, 14, &m[s[4]], &m[s[5]]);
94	quarter_round(v, 3, 7, 11, 15, &m[s[6]], &m[s[7]]);
95	// Diagonals.
96	quarter_round(v, 0, 5, 10, 15, &m[s[8]], &m[s[9]]);
97	quarter_round(v, 1, 6, 11, 12, &m[s[10]], &m[s[11]]);
98	quarter_round(v, 2, 7, 8, 13, &m[s[12]], &m[s[13]]);
99	quarter_round(v, 3, 4, 9, 14, &m[s[14]], &m[s[15]]);
100}
101
102/// Loads one 64-byte block per lane into 16 little-endian message words.
103///
104/// The words arrive one block per lane and are consumed one word per lane, so this is a transpose.
105/// Where a vector kernel covers the lane count it moves the square with shuffles instead of loads.
106#[inline(always)]
107fn load_block_words<const N: usize>(block: &[[u8; BLOCK_LEN]; N]) -> [[u32; N]; 16] {
108	#[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))]
109	if avx512::handles_lanes(N) {
110		return avx512::load_block_words(block);
111	}
112
113	#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
114	if neon::transposes_lanes(N) {
115		return neon::load_block_words(block);
116	}
117
118	load_block_words_portable(block)
119}
120
121/// Loads one 64-byte block per lane into 16 little-endian message words, one word at a time.
122///
123/// Every target without a hand-written transpose runs this.
124///
125/// It is also the reference the vector kernels are tested against.
126#[inline(always)]
127fn load_block_words_portable<const N: usize>(block: &[[u8; BLOCK_LEN]; N]) -> [[u32; N]; 16] {
128	let mut m = [[0u32; N]; 16];
129	for lane in 0..N {
130		for (w, slot) in m.iter_mut().enumerate() {
131			let off = w * 4;
132			slot[lane] = u32::from_le_bytes([
133				block[lane][off],
134				block[lane][off + 1],
135				block[lane][off + 2],
136				block[lane][off + 3],
137			]);
138		}
139	}
140	m
141}
142
143/// Compresses one 64-byte block across all `N` lanes, updating the chaining value in place.
144///
145/// The counter, block length, and flags are shared by every lane, so they broadcast.
146/// Only the input chaining value and the message differ per lane.
147#[inline(always)]
148fn compress_block<const N: usize>(
149	cv: &mut [[u32; N]; 8],
150	block: &[[u32; N]; 16],
151	counter: u64,
152	block_len: u32,
153	flags: u32,
154) {
155	// On aarch64 the hand-written vector kernel holds the same state in registers.
156	// Lane counts it does not cover fall through to the lane loops.
157	#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
158	if neon::handles_lanes(N) {
159		neon::compress_block(cv, block, counter, block_len, flags);
160		return;
161	}
162
163	compress_block_portable(cv, block, counter, block_len, flags);
164}
165
166/// Compresses one 64-byte block across all `N` lanes with plain lane loops.
167///
168/// Every target without a hand-written kernel runs this, and it is the reference the vector
169/// kernels are tested against.
170#[inline(always)]
171fn compress_block_portable<const N: usize>(
172	cv: &mut [[u32; N]; 8],
173	block: &[[u32; N]; 16],
174	counter: u64,
175	block_len: u32,
176	flags: u32,
177) {
178	// Split the 64-bit counter into its two 32-bit words.
179	let counter_lo = counter as u32;
180	let counter_hi = (counter >> 32) as u32;
181
182	// Initialize the 16-word state: CV, four IV words, counter, block length, flags.
183	let mut v: [[u32; N]; 16] = [
184		cv[0],
185		cv[1],
186		cv[2],
187		cv[3],
188		cv[4],
189		cv[5],
190		cv[6],
191		cv[7],
192		[IV[0]; N],
193		[IV[1]; N],
194		[IV[2]; N],
195		[IV[3]; N],
196		[counter_lo; N],
197		[counter_hi; N],
198		[block_len; N],
199		[flags; N],
200	];
201
202	// Run 7 rounds, each reading the message through its own schedule row.
203	// Nothing rewrites the message, so it stays exactly where the caller built it.
204	round::<0, N>(&mut v, block);
205	round::<1, N>(&mut v, block);
206	round::<2, N>(&mut v, block);
207	round::<3, N>(&mut v, block);
208	round::<4, N>(&mut v, block);
209	round::<5, N>(&mut v, block);
210	round::<6, N>(&mut v, block);
211
212	// Truncated output: h_i = v_i XOR v_{i+8}, feeding the next block or the final digest.
213	for i in 0..8 {
214		for lane in 0..N {
215			cv[i][lane] = v[i][lane] ^ v[i + 8][lane];
216		}
217	}
218}
219
220/// Broadcasts the eight IV words across `N` lanes to seed a fresh chaining value.
221#[inline(always)]
222fn broadcast_iv<const N: usize>() -> [[u32; N]; 8] {
223	array::from_fn(|w| [IV[w]; N])
224}
225
226/// Serializes one lane's eight-word chaining value into its 32-byte little-endian digest.
227#[inline(always)]
228fn serialize_cv_lane<const N: usize>(cv: &[[u32; N]; 8], lane: usize) -> [u8; OUT_LEN] {
229	let mut digest = [0u8; OUT_LEN];
230	for (w, chunk) in digest.chunks_exact_mut(4).enumerate() {
231		chunk.copy_from_slice(&cv[w][lane].to_le_bytes());
232	}
233	digest
234}
235
236/// Portable multi-lane Blake3 leaf digest over `N` messages, hashed a block at a time.
237///
238/// One chunk, so the chaining value stays at counter 0 and needs no CV stack.
239/// Each message is any length up to `CHUNK_LEN`; all `N` lanes must share that length.
240///
241/// A block is compressed only once the next block's first byte arrives, so the trailing block
242/// is deferred to finalization, where it alone carries `CHUNK_END | ROOT`.
243#[derive(Clone)]
244pub struct PortableBlake3MultiDigest<const N: usize> {
245	/// Running chaining value per lane; seeded from the IV.
246	cv: [[u32; N]; 8],
247	/// The current block being filled, one 64-byte buffer per lane.
248	block: [[u8; BLOCK_LEN]; N],
249	/// Bytes buffered in `block` so far, shared across lanes (all lanes share one length).
250	block_len: usize,
251	/// How many blocks have already been compressed into `cv`.
252	blocks_compressed: usize,
253}
254
255impl<const N: usize> Default for PortableBlake3MultiDigest<N> {
256	fn default() -> Self {
257		// Fresh chaining value at the IV, empty block buffer, nothing compressed yet.
258		Self {
259			cv: broadcast_iv(),
260			block: [[0u8; BLOCK_LEN]; N],
261			block_len: 0,
262			blocks_compressed: 0,
263		}
264	}
265}
266
267impl<const N: usize> PortableBlake3MultiDigest<N> {
268	/// Compresses the buffered block as a full, non-final block, then empties the buffer.
269	fn compress_full_block(&mut self) {
270		// Only the very first block of the chunk carries CHUNK_START.
271		let flags = if self.blocks_compressed == 0 {
272			CHUNK_START
273		} else {
274			0
275		};
276		let m = load_block_words(&self.block);
277		compress_block(&mut self.cv, &m, 0, BLOCK_LEN as u32, flags);
278		self.blocks_compressed += 1;
279		self.block_len = 0;
280	}
281
282	/// Compresses the trailing block as the chunk root and writes each lane's digest.
283	///
284	/// Runs on a copy of the state, so the hasher itself is left untouched for reset.
285	fn write_root(&self, out: &mut [MaybeUninit<Output<blake3::Hasher>>; N]) {
286		let mut cv = self.cv;
287		let mut block = self.block;
288		// Zero-pad the trailing block's unused tail, so padding never changes the digest.
289		for lane in 0..N {
290			block[lane][self.block_len..].fill(0);
291		}
292		// A single-block message has its only block be both the first and the root block.
293		let start = if self.blocks_compressed == 0 {
294			CHUNK_START
295		} else {
296			0
297		};
298		let m = load_block_words(&block);
299		compress_block(&mut cv, &m, 0, self.block_len as u32, start | CHUNK_END | ROOT);
300
301		// Serialize each lane's eight-word chaining value into its 32-byte digest.
302		for lane in 0..N {
303			out[lane].write(serialize_cv_lane(&cv, lane).into());
304		}
305	}
306}
307
308impl<const N: usize> MultiDigest<N> for PortableBlake3MultiDigest<N> {
309	type Digest = blake3::Hasher;
310
311	fn new() -> Self {
312		Self::default()
313	}
314
315	fn update(&mut self, data: [&[u8]; N]) {
316		// Per-lane read cursor into this call's input.
317		let mut consumed = [0usize; N];
318		loop {
319			// Bytes still pending this call; all present lanes share one length, so the max drives.
320			let remaining = (0..N)
321				.map(|i| data[i].len() - consumed[i])
322				.max()
323				.unwrap_or(0);
324			if remaining == 0 {
325				break;
326			}
327			// A full buffer with more input to come is a non-final block: compress and empty it.
328			if self.block_len == BLOCK_LEN {
329				self.compress_full_block();
330			}
331			// Fill the block buffer up to one block from the pending input.
332			let take = (BLOCK_LEN - self.block_len).min(remaining);
333			for lane in 0..N {
334				let avail = data[lane].len() - consumed[lane];
335				let n = take.min(avail);
336				self.block[lane][self.block_len..self.block_len + n]
337					.copy_from_slice(&data[lane][consumed[lane]..consumed[lane] + n]);
338				consumed[lane] += n;
339			}
340			self.block_len += take;
341		}
342	}
343
344	fn finalize_into(self, out: &mut [MaybeUninit<Output<Self::Digest>>; N]) {
345		self.write_root(out);
346	}
347
348	fn finalize_into_reset(&mut self, out: &mut [MaybeUninit<Output<Self::Digest>>; N]) {
349		self.write_root(out);
350		self.reset();
351	}
352
353	fn reset(&mut self) {
354		// Reseed the chaining value and forget the block progress; buffer bytes are overwritten
355		// on the next update, and the trailing block's tail is zero-padded at finalization.
356		self.cv = broadcast_iv();
357		self.block_len = 0;
358		self.blocks_compressed = 0;
359	}
360
361	fn digest(data: [&[u8]; N], out: &mut [MaybeUninit<Output<Self::Digest>>; N]) {
362		let mut hasher = Self::new();
363		hasher.update(data);
364		hasher.finalize_into(out);
365	}
366}
367
368/// Parallel Blake3 leaf digest backed by the portable auto-vectorized kernel.
369///
370/// `LANES` is the batch width handed to the vectorizer.
371/// Leaf size decides the path:
372/// - Up to one 1024-byte chunk (any length): batched through the portable kernel.
373/// - Larger (multi-chunk): hashed on its own by the scalar adapter, which walks the tree.
374#[derive(Debug, Clone, Default)]
375pub struct PortableBlake3ParallelDigest<const LANES: usize>;
376
377impl<const LANES: usize> ParallelDigest for PortableBlake3ParallelDigest<LANES> {
378	type Digest = blake3::Hasher;
379
380	fn new() -> Self {
381		Self
382	}
383
384	fn digest<I: IntoIterator<Item: SerializeBytes>>(
385		&self,
386		source: impl IndexedParallelIterator<Item = I>,
387		out: &mut [MaybeUninit<Output<Self::Digest>>],
388	) {
389		// Without a fixed leaf length a leaf could exceed one chunk, which the kernel cannot hash.
390		// Fall back to the scalar adapter, which handles any length.
391		ParallelDigestAdapter::<blake3::Hasher>::new().digest(source, out);
392	}
393
394	fn digest_with_const_len<I: IntoIterator<Item: FixedSizeSerializeBytes>>(
395		&self,
396		n_items_per_input: usize,
397		source: impl IndexedParallelIterator<Item = I>,
398		out: &mut [MaybeUninit<Output<Self::Digest>>],
399	) {
400		// Every leaf serializes to the same fixed byte length.
401		let leaf_len = n_items_per_input * I::Item::BYTE_SIZE;
402
403		if leaf_len <= CHUNK_LEN {
404			// One chunk or less, any block structure: batch it through the vectorized kernel.
405			ParallelMultidigestImpl::<PortableBlake3MultiDigest<LANES>, LANES>::new()
406				.digest(source, out);
407		} else {
408			// Multi-chunk leaves need the tree; hand them to the scalar adapter.
409			ParallelDigestAdapter::<blake3::Hasher>::new().digest(source, out);
410		}
411	}
412}
413
414/// Folds up to `N` node pairs with a single batched Blake3 block compression.
415///
416/// Each pair is a 64-byte concatenation `left || right` of two 32-byte digests.
417/// That 64-byte message is exactly one Blake3 block.
418/// A one-block message makes its single block the first, last, and root block at once:
419/// - counter   = 0       (a single chunk).
420/// - block_len = 64      (a full block).
421/// - flags     = CHUNK_START | CHUNK_END | ROOT.
422///
423/// Folds `out.len()` pairs, which must be at most `N`.
424/// `inputs.len()` must be `2 * out.len()`.
425/// A partial batch leaves the unused high lanes zero.
426/// Their output is never read.
427#[inline]
428fn compress_node_pairs<const N: usize>(
429	inputs: &[Output<blake3::Hasher>],
430	out: &mut [MaybeUninit<Output<blake3::Hasher>>],
431) {
432	// Pack each pair into one 64-byte block: bytes 0..32 = left child, 32..64 = right child.
433	let mut blocks = [[0u8; BLOCK_LEN]; N];
434	for (lane, block) in blocks.iter_mut().enumerate().take(out.len()) {
435		block[..OUT_LEN].copy_from_slice(inputs[2 * lane].as_slice());
436		block[OUT_LEN..].copy_from_slice(inputs[2 * lane + 1].as_slice());
437	}
438
439	// One block compression seeded from the IV yields each pair's two-to-one digest.
440	let m = load_block_words(&blocks);
441	let mut cv = broadcast_iv::<N>();
442	compress_block(&mut cv, &m, 0, BLOCK_LEN as u32, CHUNK_START | CHUNK_END | ROOT);
443
444	for (lane, slot) in out.iter_mut().enumerate() {
445		slot.write(serialize_cv_lane(&cv, lane).into());
446	}
447}
448
449/// Parallel Blake3 two-to-one compression backed by the portable auto-vectorized kernel.
450///
451/// The Merkle inner-node counterpart to the leaf digest [`PortableBlake3ParallelDigest`].
452/// Every parent folds a pair of 32-byte child digests as `blake3(left || right)`.
453/// A batch of `LANES` node pairs is a fixed-length batched Blake3 over 64-byte messages.
454/// Each batch runs through the shared block-compression core in one pass.
455///
456/// Output is bit-identical to the scalar [`Blake3Compression`], pinned to it in tests.
457#[derive(Debug, Clone, Default)]
458pub struct PortableBlake3ParallelCompression<const LANES: usize> {
459	/// The scalar two-to-one function this batched path reproduces, exposed via `compression()`.
460	compression: Blake3Compression,
461}
462
463impl<const LANES: usize> ParallelPseudoCompression<Output<blake3::Hasher>, 2>
464	for PortableBlake3ParallelCompression<LANES>
465{
466	type Compression = Blake3Compression;
467
468	fn compression(&self) -> &Self::Compression {
469		&self.compression
470	}
471
472	fn parallel_compress(
473		&self,
474		inputs: &[Output<blake3::Hasher>],
475		out: &mut [MaybeUninit<Output<blake3::Hasher>>],
476	) {
477		assert_eq!(inputs.len(), 2 * out.len(), "Input length must be 2 * output length");
478
479		// Fold `LANES` pairs per batch.
480		// A shorter trailing batch is fine: the kernel only reads its valid lanes.
481		inputs
482			.par_chunks(2 * LANES)
483			.zip(out.par_chunks_mut(LANES))
484			.for_each(|(in_batch, out_batch)| compress_node_pairs::<LANES>(in_batch, out_batch));
485	}
486}
487
488#[cfg(test)]
489mod tests {
490	use std::iter::repeat_with;
491
492	use binius_hash::CompressionFunction;
493	use binius_utils::rayon::iter::{IntoParallelRefIterator, ParallelIterator};
494	use proptest::prelude::*;
495	use rand::{Rng, SeedableRng, rngs::StdRng};
496
497	use super::*;
498
499	/// Folds `pairs` through the `N`-lane portable compression.
500	/// Pins every output bit-identical to the scalar [`Blake3Compression`].
501	fn check_parallel_compression<const N: usize>(pairs: &[[[u8; OUT_LEN]; 2]]) {
502		// Flatten pairs to the `[left_0, right_0, left_1, ...]` layout the compressor reads.
503		let inputs: Vec<Output<blake3::Hasher>> = pairs
504			.iter()
505			.flat_map(|[l, r]| [(*l).into(), (*r).into()])
506			.collect();
507		let mut out = repeat_with(MaybeUninit::<Output<blake3::Hasher>>::uninit)
508			.take(pairs.len())
509			.collect::<Vec<_>>();
510
511		PortableBlake3ParallelCompression::<N>::default().parallel_compress(&inputs, &mut out);
512
513		// Invariant: each batched lane equals the scalar two-to-one of the same pair.
514		for (slot, [l, r]) in out.into_iter().zip(pairs) {
515			let expected = Blake3Compression.compress([(*l).into(), (*r).into()]);
516			assert_eq!(unsafe { slot.assume_init() }.as_slice(), expected.as_slice());
517		}
518	}
519
520	#[test]
521	fn test_parallel_compression_boundaries() {
522		// Extreme digests exercise all-zero and all-ones message blocks in every left/right slot.
523		let zero = [0u8; OUT_LEN];
524		let ones = [0xffu8; OUT_LEN];
525		check_parallel_compression::<16>(&[[zero, zero], [ones, ones], [zero, ones], [ones, zero]]);
526
527		// Counts straddling the 16-lane batch boundary: empty, single, full, full+1, multi+partial.
528		let mut rng = StdRng::seed_from_u64(7);
529		for count in [0usize, 1, 15, 16, 17, 33] {
530			let pairs: Vec<[[u8; OUT_LEN]; 2]> = (0..count)
531				.map(|_| {
532					let mut pair = [[0u8; OUT_LEN]; 2];
533					rng.fill_bytes(&mut pair[0]);
534					rng.fill_bytes(&mut pair[1]);
535					pair
536				})
537				.collect();
538			check_parallel_compression::<16>(&pairs);
539		}
540	}
541
542	proptest! {
543		#[test]
544		fn parallel_compression_matches_scalar(
545			pairs in prop::collection::vec(
546				(prop::array::uniform32(any::<u8>()), prop::array::uniform32(any::<u8>())),
547				0..40usize,
548			),
549		) {
550			// The batch path is bit-identical to the scalar reference, at every vectorizer width.
551			let pairs: Vec<[[u8; OUT_LEN]; 2]> = pairs.into_iter().map(|(l, r)| [l, r]).collect();
552			check_parallel_compression::<4>(&pairs);
553			check_parallel_compression::<8>(&pairs);
554			check_parallel_compression::<16>(&pairs);
555		}
556	}
557
558	/// Transposes one random block through both loaders and pins the vector words to the byte-wise
559	/// words.
560	///
561	/// # Arguments
562	///
563	/// * `rng` - source of the random block bytes.
564	#[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))]
565	fn check_avx512_transpose<const N: usize>(rng: &mut StdRng) {
566		use rand::RngExt;
567
568		// Every lane gets its own random bytes.
569		// Sharing bytes across lanes would hide a network that reads the wrong row.
570		let block: [[u8; BLOCK_LEN]; N] = array::from_fn(|_| array::from_fn(|_| rng.random()));
571
572		// Reference: the byte-wise loader every target without a transpose runs.
573		let want = load_block_words_portable(&block);
574
575		// Candidate: the shuffle network, over the same bytes.
576		let got = super::avx512::load_block_words(&block);
577
578		// A transpose only permutes words, so the two must agree on all 16 words of all N lanes.
579		assert_eq!(got, want, "vector transpose diverged from the byte-wise loader at {N} lanes");
580	}
581
582	#[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))]
583	#[test]
584	fn test_avx512_transpose_matches_portable() {
585		let mut rng = StdRng::seed_from_u64(13);
586
587		// A misplaced row shows up only when the bytes differ, so repeat over fresh random blocks.
588		for _ in 0..64 {
589			check_avx512_transpose::<16>(&mut rng);
590		}
591	}
592
593	#[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))]
594	#[test]
595	fn test_avx512_transpose_places_every_word() {
596		// A counting block makes each word its own (lane, word) coordinate.
597		// Any swapped row or lane then shows up as a wrong coordinate, not just wrong bytes.
598		let block: [[u8; BLOCK_LEN]; 16] = array::from_fn(|lane| {
599			array::from_fn(|byte| {
600				let word = byte / 4;
601				// Word `w` of lane `l` is the value `l * 16 + w`, little-endian.
602				match byte % 4 {
603					0 => (lane * 16 + word) as u8,
604					_ => 0,
605				}
606			})
607		});
608
609		let m = super::avx512::load_block_words(&block);
610
611		// Word `w` of lane `l` must land at `m[w][l]`.
612		for lane in 0..16 {
613			for word in 0..16 {
614				assert_eq!(
615					m[word][lane],
616					(lane * 16 + word) as u32,
617					"word {word} of lane {lane} landed wrongly"
618				);
619			}
620		}
621	}
622
623	#[cfg(all(target_arch = "x86_64", target_feature = "avx512f"))]
624	proptest! {
625		#[test]
626		fn avx512_transpose_matches_portable_proptest(seed in any::<u64>()) {
627			// The fixed cases above pin the layout; this sweeps arbitrary bytes.
628			let mut rng = StdRng::seed_from_u64(seed);
629			check_avx512_transpose::<16>(&mut rng);
630		}
631	}
632
633	/// Compresses one block through both cores and pins the vector words to the lane-loop words.
634	///
635	/// # Arguments
636	///
637	/// * `rng` - source of the random chaining value and message.
638	/// * `counter` - chunk counter to place in the state.
639	/// * `block_len` - message byte count to place in the state.
640	/// * `flags` - domain-separation flags to place in the state.
641	#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
642	fn check_neon_core<const N: usize>(rng: &mut StdRng, counter: u64, block_len: u32, flags: u32) {
643		use rand::RngExt;
644
645		// Every lane gets its own random words.
646		// Sharing a value across lanes would hide a kernel that reads the wrong vector.
647		let cv_in: [[u32; N]; 8] = array::from_fn(|_| array::from_fn(|_| rng.random()));
648		let block: [[u32; N]; 16] = array::from_fn(|_| array::from_fn(|_| rng.random()));
649
650		// Reference: the lane loops every target without a vector kernel runs.
651		let mut want = cv_in;
652		compress_block_portable(&mut want, &block, counter, block_len, flags);
653
654		// Candidate: the vector kernel, started from the same chaining value.
655		let mut got = cv_in;
656		super::neon::compress_block(&mut got, &block, counter, block_len, flags);
657
658		// Compression is bit-exact, so the two must agree on all 8 words of all N lanes.
659		assert_eq!(got, want, "vector kernel diverged from the lane loops at {N} lanes");
660	}
661
662	/// Transposes one random block set through both loaders and pins the words together.
663	#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
664	fn check_neon_transpose<const N: usize>(rng: &mut StdRng) {
665		// Fresh random bytes per lane, so no two lanes hold the same word by accident.
666		let mut blocks = [[0u8; BLOCK_LEN]; N];
667		for block in blocks.iter_mut() {
668			rng.fill_bytes(block);
669		}
670
671		// A transpose only permutes words, so the two loaders must agree on all 16 rows.
672		assert_eq!(
673			super::neon::load_block_words(&blocks),
674			load_block_words_portable(&blocks),
675			"the shuffle network diverged from the byte-wise loader at {N} lanes"
676		);
677	}
678
679	#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
680	#[test]
681	fn test_neon_transpose_matches_portable() {
682		// Invariant: a word's output row is its word index, and its output lane is its block index.
683		//
684		// Fixture: word `w` of lane `l` carries the value `l * 16 + w`, so all 16 * N are distinct.
685		//
686		//     lane 0 block:  [  0,  1,  2, ...,  15 ]
687		//     lane 1 block:  [ 16, 17, 18, ...,  31 ]
688		//     ...
689		//     output row w:  [  w, 16 + w, 32 + w, ... ]
690		//
691		// Distinct values are what makes a swapped row or lane show up as a wrong value.
692		let mut blocks = [[0u8; BLOCK_LEN]; 16];
693		for (lane, block) in blocks.iter_mut().enumerate() {
694			for (w, word) in block.chunks_exact_mut(4).enumerate() {
695				word.copy_from_slice(&((lane * 16 + w) as u32).to_le_bytes());
696			}
697		}
698
699		// Every one of the 16 rows must read back its own coordinate, at every lane.
700		let m = super::neon::load_block_words(&blocks);
701		for (w, row) in m.iter().enumerate() {
702			for (lane, got) in row.iter().enumerate() {
703				assert_eq!(*got, (lane * 16 + w) as u32, "row {w}, lane {lane}");
704			}
705		}
706
707		// Random blocks at every width the network claims, from one square up to four.
708		let mut rng = StdRng::seed_from_u64(23);
709		check_neon_transpose::<4>(&mut rng);
710		check_neon_transpose::<8>(&mut rng);
711		check_neon_transpose::<12>(&mut rng);
712		check_neon_transpose::<16>(&mut rng);
713	}
714
715	#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
716	proptest! {
717		#[test]
718		fn neon_transpose_matches_portable_proptest(seed in any::<u64>()) {
719			// The fixed block above pins the coordinates.
720			// This sweeps arbitrary bytes at every width instead.
721			let mut rng = StdRng::seed_from_u64(seed);
722			check_neon_transpose::<4>(&mut rng);
723			check_neon_transpose::<8>(&mut rng);
724			check_neon_transpose::<12>(&mut rng);
725			check_neon_transpose::<16>(&mut rng);
726		}
727	}
728
729	#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
730	#[test]
731	fn test_neon_core_matches_portable() {
732		let mut rng = StdRng::seed_from_u64(11);
733
734		// The last four state words are the ones a kernel is most likely to place wrongly.
735		// So cover the combinations a real chunk produces, plus the extremes of each field.
736		//
737		//     (counter, block_len, flags)
738		let cases = [
739			// Interior block of a multi-block chunk: no flags, full 64 bytes.
740			(0u64, 64u32, 0u32),
741			// First block of a chunk.
742			(0, 64, CHUNK_START),
743			// Last block of a chunk, and the last of the whole tree.
744			(0, 64, CHUNK_END | ROOT),
745			// A chunk that is one block long, so it carries every flag at once.
746			(0, 64, CHUNK_START | CHUNK_END | ROOT),
747			// A one-byte message: the block is almost entirely zero padding.
748			(0, 1, CHUNK_START | CHUNK_END | ROOT),
749			// The empty message, the shortest input Blake3 accepts.
750			(0, 0, CHUNK_START | CHUNK_END | ROOT),
751			// Both counter halves set, which catches a kernel that drops the high half.
752			(u64::MAX, 64, CHUNK_END),
753			// A counter whose low half is zero, which catches the two halves being swapped.
754			(1 << 32, 63, CHUNK_START),
755		];
756
757		// Check every lane count the kernel claims, from one vector group up to four.
758		for (counter, block_len, flags) in cases {
759			check_neon_core::<4>(&mut rng, counter, block_len, flags);
760			check_neon_core::<8>(&mut rng, counter, block_len, flags);
761			check_neon_core::<12>(&mut rng, counter, block_len, flags);
762			check_neon_core::<16>(&mut rng, counter, block_len, flags);
763		}
764	}
765
766	#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
767	proptest! {
768		#[test]
769		fn neon_core_matches_portable_proptest(
770			seed in any::<u64>(),
771			counter in any::<u64>(),
772			// A block never carries more than the 64 bytes it holds.
773			block_len in 0..=64u32,
774			// Blake3 defines flags in the low byte only.
775			flags in any::<u8>(),
776		) {
777			// The fixed cases above pin the boundaries; this sweeps arbitrary state at every width.
778			let mut rng = StdRng::seed_from_u64(seed);
779			check_neon_core::<4>(&mut rng, counter, block_len, flags as u32);
780			check_neon_core::<8>(&mut rng, counter, block_len, flags as u32);
781			check_neon_core::<12>(&mut rng, counter, block_len, flags as u32);
782			check_neon_core::<16>(&mut rng, counter, block_len, flags as u32);
783		}
784	}
785
786	/// Runs `N` equal-length messages of `len` bytes through the portable batch and pins each lane
787	/// to the scalar reference.
788	fn check_portable_batch<const N: usize>(rng: &mut StdRng, len: usize) {
789		// Fresh random bytes per lane, so lanes don't share a digest by accident.
790		let messages: [Vec<u8>; N] = array::from_fn(|_| {
791			let mut m = vec![0u8; len];
792			rng.fill_bytes(&mut m);
793			m
794		});
795		let refs: [&[u8]; N] = array::from_fn(|i| messages[i].as_slice());
796		let mut out = array::from_fn::<_, N, _>(|_| MaybeUninit::uninit());
797		PortableBlake3MultiDigest::<N>::digest(refs, &mut out);
798
799		// Each lane's output must equal the single-message reference hash of that lane.
800		for (o, message) in out.iter().zip(messages.iter()) {
801			let got = unsafe { o.assume_init_ref() };
802			assert_eq!(got.as_slice(), blake3::hash(message).as_bytes(), "len = {len}, N = {N}");
803		}
804	}
805
806	#[test]
807	fn test_portable_lengths_match_reference() {
808		let mut rng = StdRng::seed_from_u64(0);
809
810		// Invariant: the portable kernel reproduces blake3::hash for any single-chunk length.
811		// Lengths cover every block-structure case within one chunk:
812		// - 0             : the lone empty block.
813		// - 1, 31, 63     : a single sub-block, no full blocks.
814		// - 64, 128, 1024 : exact block multiples.
815		// - 65, 100, 1000 : leading full blocks plus a partial tail.
816		// Three lane widths per length: 4 (NEON), 8, and 16 (the throughput sweet spot).
817		for len in [0, 1, 31, 63, 64, 65, 100, 127, 128, 1000, 1024] {
818			check_portable_batch::<4>(&mut rng, len);
819			check_portable_batch::<8>(&mut rng, len);
820			check_portable_batch::<16>(&mut rng, len);
821		}
822	}
823
824	#[test]
825	fn test_portable_chained_update() {
826		let mut rng = StdRng::seed_from_u64(2);
827		// Four 200-byte messages: three full blocks plus a 8-byte partial tail.
828		let messages: [Vec<u8>; 4] = array::from_fn(|_| {
829			let mut m = vec![0u8; 200];
830			rng.fill_bytes(&mut m);
831			m
832		});
833
834		// Invariant: a message split across two updates hashes the same as one update of the whole.
835		// The 50/150 split lands mid-block, exercising the buffer-fill and deferred-compress paths.
836		let mut hasher = PortableBlake3MultiDigest::<4>::new();
837		hasher.update(array::from_fn(|i| &messages[i][..50]));
838		hasher.update(array::from_fn(|i| &messages[i][50..]));
839		let mut out = array::from_fn::<_, 4, _>(|_| MaybeUninit::uninit());
840		hasher.finalize_into(&mut out);
841
842		for (o, message) in out.iter().zip(messages.iter()) {
843			assert_eq!(unsafe { o.assume_init_ref() }.as_slice(), blake3::hash(message).as_bytes());
844		}
845	}
846
847	#[test]
848	fn test_portable_routing_matches_reference() {
849		let mut rng = StdRng::seed_from_u64(3);
850		// Build 50 leaves of `leaf_len` bytes each, fed as u8 items (BYTE_SIZE = 1).
851		let mut check = |leaf_len: usize| {
852			let leaves: Vec<Vec<u8>> = (0..50)
853				.map(|_| {
854					let mut m = vec![0u8; leaf_len];
855					rng.fill_bytes(&mut m);
856					m
857				})
858				.collect();
859			let digest = PortableBlake3ParallelDigest::<8>::new();
860			let mut results = repeat_with(MaybeUninit::<Output<blake3::Hasher>>::uninit)
861				.take(50)
862				.collect::<Vec<_>>();
863			digest.digest_with_const_len(
864				leaf_len,
865				leaves.par_iter().map(|leaf| leaf.iter().copied()),
866				&mut results,
867			);
868			for (result, leaf) in results.into_iter().zip(&leaves) {
869				let got = unsafe { result.assume_init() };
870				assert_eq!(got.as_slice(), blake3::hash(leaf).as_bytes(), "leaf_len {leaf_len}");
871			}
872		};
873
874		// Invariant: every leaf size reproduces the reference, on the batch or the adapter route.
875		// - 0, 1, 63      : sub-block             -> portable batch.
876		// - 65, 100, 1000 : partial trailing block -> portable batch.
877		// - 64, 1024      : whole blocks           -> portable batch.
878		// - 1025, 2048    : multi-chunk (> 1024)   -> scalar adapter.
879		for leaf_len in [0, 1, 63, 64, 65, 100, 1000, 1024, 1025, 2048] {
880			check(leaf_len);
881		}
882	}
883}