Skip to main content

binius_hash/
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.
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//! Output is bit-identical to `blake3::hash`, pinned to the reference in tests.
22//! Scope: any message up to one 1024-byte chunk, including sub-block and partial-block leaves.
23
24use std::{array, mem::MaybeUninit};
25
26use binius_utils::{
27	FixedSizeSerializeBytes, SerializeBytes,
28	rayon::{
29		iter::{IndexedParallelIterator, ParallelIterator},
30		slice::{ParallelSlice, ParallelSliceMut},
31	},
32};
33use blake3::{BLOCK_LEN, CHUNK_LEN, OUT_LEN};
34use digest::Output;
35
36use super::{
37	blake3::Blake3Compression,
38	parallel_compression::ParallelPseudoCompression,
39	parallel_digest::{
40		MultiDigest, ParallelDigest, ParallelDigestAdapter, ParallelMultidigestImpl,
41	},
42};
43
44/// Blake3 domain-separation flag marking the first block of a chunk.
45const CHUNK_START: u32 = 1 << 0;
46
47/// Blake3 domain-separation flag marking the last block of a chunk.
48const CHUNK_END: u32 = 1 << 1;
49
50/// Blake3 domain-separation flag marking the last block of the whole tree.
51const ROOT: u32 = 1 << 3;
52
53/// Blake3 initial chaining value: the eight IV words, identical to the SHA-256 IV.
54const IV: [u32; 8] = [
55	0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
56];
57
58/// Blake3 message permutation applied between rounds.
59///
60/// The single fixed schedule from section 2.2 of the Blake3 spec, Table 2.
61const MSG_PERMUTATION: [usize; 16] = [2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8];
62
63/// The 7-round count of the Blake3 keyed permutation.
64const N_ROUNDS: usize = 7;
65
66/// Applies one Blake3 quarter-round across all `N` lanes.
67///
68/// The state words at positions `a, b, c, d` are mixed with two message words per lane.
69/// Every line is an independent `0..N` map, which is what the vectorizer turns into SIMD.
70#[inline(always)]
71fn quarter_round<const N: usize>(
72	v: &mut [[u32; N]; 16],
73	a: usize,
74	b: usize,
75	c: usize,
76	d: usize,
77	mx: &[u32; N],
78	my: &[u32; N],
79) {
80	// One lane per iteration; lanes are independent, so the loop vectorizes.
81	for i in 0..N {
82		v[a][i] = v[a][i].wrapping_add(v[b][i]).wrapping_add(mx[i]);
83		v[d][i] = (v[d][i] ^ v[a][i]).rotate_right(16);
84		v[c][i] = v[c][i].wrapping_add(v[d][i]);
85		v[b][i] = (v[b][i] ^ v[c][i]).rotate_right(12);
86		v[a][i] = v[a][i].wrapping_add(v[b][i]).wrapping_add(my[i]);
87		v[d][i] = (v[d][i] ^ v[a][i]).rotate_right(8);
88		v[c][i] = v[c][i].wrapping_add(v[d][i]);
89		v[b][i] = (v[b][i] ^ v[c][i]).rotate_right(7);
90	}
91}
92
93/// Applies one full Blake3 round: four column mixes, then four diagonal mixes.
94///
95/// Message words are consumed in order `m[0..16]`, two per quarter-round.
96#[inline(always)]
97fn round<const N: usize>(v: &mut [[u32; N]; 16], m: &[[u32; N]; 16]) {
98	// Columns.
99	quarter_round(v, 0, 4, 8, 12, &m[0], &m[1]);
100	quarter_round(v, 1, 5, 9, 13, &m[2], &m[3]);
101	quarter_round(v, 2, 6, 10, 14, &m[4], &m[5]);
102	quarter_round(v, 3, 7, 11, 15, &m[6], &m[7]);
103	// Diagonals.
104	quarter_round(v, 0, 5, 10, 15, &m[8], &m[9]);
105	quarter_round(v, 1, 6, 11, 12, &m[10], &m[11]);
106	quarter_round(v, 2, 7, 8, 13, &m[12], &m[13]);
107	quarter_round(v, 3, 4, 9, 14, &m[14], &m[15]);
108}
109
110/// Permutes the message words in place for the next round.
111#[inline(always)]
112fn permute<const N: usize>(m: &mut [[u32; N]; 16]) {
113	// The permutation reads each slot from its source, so build into a fresh array.
114	let permuted: [[u32; N]; 16] = array::from_fn(|i| m[MSG_PERMUTATION[i]]);
115	*m = permuted;
116}
117
118/// Loads one 64-byte block per lane into 16 little-endian message words.
119#[inline(always)]
120fn load_block_words<const N: usize>(block: &[[u8; BLOCK_LEN]; N]) -> [[u32; N]; 16] {
121	let mut m = [[0u32; N]; 16];
122	for lane in 0..N {
123		for (w, slot) in m.iter_mut().enumerate() {
124			let off = w * 4;
125			slot[lane] = u32::from_le_bytes([
126				block[lane][off],
127				block[lane][off + 1],
128				block[lane][off + 2],
129				block[lane][off + 3],
130			]);
131		}
132	}
133	m
134}
135
136/// Compresses one 64-byte block across all `N` lanes, updating the chaining value in place.
137///
138/// The counter, block length, and flags are shared by every lane, so they broadcast.
139/// Only the input chaining value and the message differ per lane.
140#[inline(always)]
141fn compress_block<const N: usize>(
142	cv: &mut [[u32; N]; 8],
143	block: &[[u32; N]; 16],
144	counter: u64,
145	block_len: u32,
146	flags: u32,
147) {
148	// Split the 64-bit counter into its two 32-bit words.
149	let counter_lo = counter as u32;
150	let counter_hi = (counter >> 32) as u32;
151
152	// Initialize the 16-word state: CV, four IV words, counter, block length, flags.
153	let mut v: [[u32; N]; 16] = [
154		cv[0],
155		cv[1],
156		cv[2],
157		cv[3],
158		cv[4],
159		cv[5],
160		cv[6],
161		cv[7],
162		[IV[0]; N],
163		[IV[1]; N],
164		[IV[2]; N],
165		[IV[3]; N],
166		[counter_lo; N],
167		[counter_hi; N],
168		[block_len; N],
169		[flags; N],
170	];
171
172	// Run 7 rounds; permute the message between all but the last.
173	let mut m = *block;
174	for r in 0..N_ROUNDS {
175		round(&mut v, &m);
176		if r < N_ROUNDS - 1 {
177			permute(&mut m);
178		}
179	}
180
181	// Truncated output: h_i = v_i XOR v_{i+8}, feeding the next block or the final digest.
182	for i in 0..8 {
183		for lane in 0..N {
184			cv[i][lane] = v[i][lane] ^ v[i + 8][lane];
185		}
186	}
187}
188
189/// Broadcasts the eight IV words across `N` lanes to seed a fresh chaining value.
190#[inline(always)]
191fn broadcast_iv<const N: usize>() -> [[u32; N]; 8] {
192	array::from_fn(|w| [IV[w]; N])
193}
194
195/// Serializes one lane's eight-word chaining value into its 32-byte little-endian digest.
196#[inline(always)]
197fn serialize_cv_lane<const N: usize>(cv: &[[u32; N]; 8], lane: usize) -> [u8; OUT_LEN] {
198	let mut digest = [0u8; OUT_LEN];
199	for (w, chunk) in digest.chunks_exact_mut(4).enumerate() {
200		chunk.copy_from_slice(&cv[w][lane].to_le_bytes());
201	}
202	digest
203}
204
205/// Portable multi-lane Blake3 leaf digest over `N` messages, hashed a block at a time.
206///
207/// One chunk, so the chaining value stays at counter 0 and needs no CV stack.
208/// Each message is any length up to `CHUNK_LEN`; all `N` lanes must share that length.
209///
210/// A block is compressed only once the next block's first byte arrives, so the trailing block
211/// is deferred to finalization, where it alone carries `CHUNK_END | ROOT`.
212#[derive(Clone)]
213pub struct PortableBlake3MultiDigest<const N: usize> {
214	/// Running chaining value per lane; seeded from the IV.
215	cv: [[u32; N]; 8],
216	/// The current block being filled, one 64-byte buffer per lane.
217	block: [[u8; BLOCK_LEN]; N],
218	/// Bytes buffered in `block` so far, shared across lanes (all lanes share one length).
219	block_len: usize,
220	/// How many blocks have already been compressed into `cv`.
221	blocks_compressed: usize,
222}
223
224impl<const N: usize> Default for PortableBlake3MultiDigest<N> {
225	fn default() -> Self {
226		// Fresh chaining value at the IV, empty block buffer, nothing compressed yet.
227		Self {
228			cv: broadcast_iv(),
229			block: [[0u8; BLOCK_LEN]; N],
230			block_len: 0,
231			blocks_compressed: 0,
232		}
233	}
234}
235
236impl<const N: usize> PortableBlake3MultiDigest<N> {
237	/// Compresses the buffered block as a full, non-final block, then empties the buffer.
238	fn compress_full_block(&mut self) {
239		// Only the very first block of the chunk carries CHUNK_START.
240		let flags = if self.blocks_compressed == 0 {
241			CHUNK_START
242		} else {
243			0
244		};
245		let m = load_block_words(&self.block);
246		compress_block(&mut self.cv, &m, 0, BLOCK_LEN as u32, flags);
247		self.blocks_compressed += 1;
248		self.block_len = 0;
249	}
250
251	/// Compresses the trailing block as the chunk root and writes each lane's digest.
252	///
253	/// Runs on a copy of the state, so the hasher itself is left untouched for reset.
254	fn write_root(&self, out: &mut [MaybeUninit<Output<blake3::Hasher>>; N]) {
255		let mut cv = self.cv;
256		let mut block = self.block;
257		// Zero-pad the trailing block's unused tail, so padding never changes the digest.
258		for lane in 0..N {
259			block[lane][self.block_len..].fill(0);
260		}
261		// A single-block message has its only block be both the first and the root block.
262		let start = if self.blocks_compressed == 0 {
263			CHUNK_START
264		} else {
265			0
266		};
267		let m = load_block_words(&block);
268		compress_block(&mut cv, &m, 0, self.block_len as u32, start | CHUNK_END | ROOT);
269
270		// Serialize each lane's eight-word chaining value into its 32-byte digest.
271		for lane in 0..N {
272			out[lane].write(serialize_cv_lane(&cv, lane).into());
273		}
274	}
275}
276
277impl<const N: usize> MultiDigest<N> for PortableBlake3MultiDigest<N> {
278	type Digest = blake3::Hasher;
279
280	fn new() -> Self {
281		Self::default()
282	}
283
284	fn update(&mut self, data: [&[u8]; N]) {
285		// Per-lane read cursor into this call's input.
286		let mut consumed = [0usize; N];
287		loop {
288			// Bytes still pending this call; all present lanes share one length, so the max drives.
289			let remaining = (0..N)
290				.map(|i| data[i].len() - consumed[i])
291				.max()
292				.unwrap_or(0);
293			if remaining == 0 {
294				break;
295			}
296			// A full buffer with more input to come is a non-final block: compress and empty it.
297			if self.block_len == BLOCK_LEN {
298				self.compress_full_block();
299			}
300			// Fill the block buffer up to one block from the pending input.
301			let take = (BLOCK_LEN - self.block_len).min(remaining);
302			for lane in 0..N {
303				let avail = data[lane].len() - consumed[lane];
304				let n = take.min(avail);
305				self.block[lane][self.block_len..self.block_len + n]
306					.copy_from_slice(&data[lane][consumed[lane]..consumed[lane] + n]);
307				consumed[lane] += n;
308			}
309			self.block_len += take;
310		}
311	}
312
313	fn finalize_into(self, out: &mut [MaybeUninit<Output<Self::Digest>>; N]) {
314		self.write_root(out);
315	}
316
317	fn finalize_into_reset(&mut self, out: &mut [MaybeUninit<Output<Self::Digest>>; N]) {
318		self.write_root(out);
319		self.reset();
320	}
321
322	fn reset(&mut self) {
323		// Reseed the chaining value and forget the block progress; buffer bytes are overwritten
324		// on the next update, and the trailing block's tail is zero-padded at finalization.
325		self.cv = broadcast_iv();
326		self.block_len = 0;
327		self.blocks_compressed = 0;
328	}
329
330	fn digest(data: [&[u8]; N], out: &mut [MaybeUninit<Output<Self::Digest>>; N]) {
331		let mut hasher = Self::new();
332		hasher.update(data);
333		hasher.finalize_into(out);
334	}
335}
336
337/// Parallel Blake3 leaf digest backed by the portable auto-vectorized kernel.
338///
339/// `LANES` is the batch width handed to the vectorizer.
340/// Leaf size decides the path:
341/// - Up to one 1024-byte chunk (any length): batched through the portable kernel.
342/// - Larger (multi-chunk): hashed on its own by the scalar adapter, which walks the tree.
343#[derive(Debug, Clone, Default)]
344pub struct PortableBlake3ParallelDigest<const LANES: usize>;
345
346impl<const LANES: usize> ParallelDigest for PortableBlake3ParallelDigest<LANES> {
347	type Digest = blake3::Hasher;
348
349	fn new() -> Self {
350		Self
351	}
352
353	fn digest<I: IntoIterator<Item: SerializeBytes>>(
354		&self,
355		source: impl IndexedParallelIterator<Item = I>,
356		out: &mut [MaybeUninit<Output<Self::Digest>>],
357	) {
358		// Without a fixed leaf length a leaf could exceed one chunk, which the kernel cannot hash.
359		// Fall back to the scalar adapter, which handles any length.
360		ParallelDigestAdapter::<blake3::Hasher>::new().digest(source, out);
361	}
362
363	fn digest_with_const_len<I: IntoIterator<Item: FixedSizeSerializeBytes>>(
364		&self,
365		n_items_per_input: usize,
366		source: impl IndexedParallelIterator<Item = I>,
367		out: &mut [MaybeUninit<Output<Self::Digest>>],
368	) {
369		// Every leaf serializes to the same fixed byte length.
370		let leaf_len = n_items_per_input * I::Item::BYTE_SIZE;
371
372		if leaf_len <= CHUNK_LEN {
373			// One chunk or less, any block structure: batch it through the vectorized kernel.
374			ParallelMultidigestImpl::<PortableBlake3MultiDigest<LANES>, LANES>::new()
375				.digest(source, out);
376		} else {
377			// Multi-chunk leaves need the tree; hand them to the scalar adapter.
378			ParallelDigestAdapter::<blake3::Hasher>::new().digest(source, out);
379		}
380	}
381}
382
383/// Folds up to `N` node pairs with a single batched Blake3 block compression.
384///
385/// Each pair is a 64-byte concatenation `left || right` of two 32-byte digests.
386/// That 64-byte message is exactly one Blake3 block.
387/// A one-block message makes its single block the first, last, and root block at once:
388/// - counter   = 0       (a single chunk).
389/// - block_len = 64      (a full block).
390/// - flags     = CHUNK_START | CHUNK_END | ROOT.
391///
392/// Folds `out.len()` pairs, which must be at most `N`.
393/// `inputs.len()` must be `2 * out.len()`.
394/// A partial batch leaves the unused high lanes zero.
395/// Their output is never read.
396#[inline]
397fn compress_node_pairs<const N: usize>(
398	inputs: &[Output<blake3::Hasher>],
399	out: &mut [MaybeUninit<Output<blake3::Hasher>>],
400) {
401	// Pack each pair into one 64-byte block: bytes 0..32 = left child, 32..64 = right child.
402	let mut blocks = [[0u8; BLOCK_LEN]; N];
403	for (lane, block) in blocks.iter_mut().enumerate().take(out.len()) {
404		block[..OUT_LEN].copy_from_slice(inputs[2 * lane].as_slice());
405		block[OUT_LEN..].copy_from_slice(inputs[2 * lane + 1].as_slice());
406	}
407
408	// One block compression seeded from the IV yields each pair's two-to-one digest.
409	let m = load_block_words(&blocks);
410	let mut cv = broadcast_iv::<N>();
411	compress_block(&mut cv, &m, 0, BLOCK_LEN as u32, CHUNK_START | CHUNK_END | ROOT);
412
413	for (lane, slot) in out.iter_mut().enumerate() {
414		slot.write(serialize_cv_lane(&cv, lane).into());
415	}
416}
417
418/// Parallel Blake3 two-to-one compression backed by the portable auto-vectorized kernel.
419///
420/// The Merkle inner-node counterpart to the leaf digest [`PortableBlake3ParallelDigest`].
421/// Every parent folds a pair of 32-byte child digests as `blake3(left || right)`.
422/// A batch of `LANES` node pairs is a fixed-length batched Blake3 over 64-byte messages.
423/// Each batch runs through the shared block-compression core in one pass.
424///
425/// Output is bit-identical to the scalar [`Blake3Compression`], pinned to it in tests.
426#[derive(Debug, Clone, Default)]
427pub struct PortableBlake3ParallelCompression<const LANES: usize> {
428	/// The scalar two-to-one function this batched path reproduces, exposed via `compression()`.
429	compression: Blake3Compression,
430}
431
432impl<const LANES: usize> ParallelPseudoCompression<Output<blake3::Hasher>, 2>
433	for PortableBlake3ParallelCompression<LANES>
434{
435	type Compression = Blake3Compression;
436
437	fn compression(&self) -> &Self::Compression {
438		&self.compression
439	}
440
441	fn parallel_compress(
442		&self,
443		inputs: &[Output<blake3::Hasher>],
444		out: &mut [MaybeUninit<Output<blake3::Hasher>>],
445	) {
446		assert_eq!(inputs.len(), 2 * out.len(), "Input length must be 2 * output length");
447
448		// Fold `LANES` pairs per batch.
449		// A shorter trailing batch is fine: the kernel only reads its valid lanes.
450		inputs
451			.par_chunks(2 * LANES)
452			.zip(out.par_chunks_mut(LANES))
453			.for_each(|(in_batch, out_batch)| compress_node_pairs::<LANES>(in_batch, out_batch));
454	}
455}
456
457#[cfg(test)]
458mod tests {
459	use std::iter::repeat_with;
460
461	use binius_utils::rayon::iter::{IntoParallelRefIterator, ParallelIterator};
462	use proptest::prelude::*;
463	use rand::{Rng, SeedableRng, rngs::StdRng};
464
465	use super::{super::compress::CompressionFunction, *};
466
467	/// Folds `pairs` through the `N`-lane portable compression.
468	/// Pins every output bit-identical to the scalar [`Blake3Compression`].
469	fn check_parallel_compression<const N: usize>(pairs: &[[[u8; OUT_LEN]; 2]]) {
470		// Flatten pairs to the `[left_0, right_0, left_1, ...]` layout the compressor reads.
471		let inputs: Vec<Output<blake3::Hasher>> = pairs
472			.iter()
473			.flat_map(|[l, r]| [(*l).into(), (*r).into()])
474			.collect();
475		let mut out = repeat_with(MaybeUninit::<Output<blake3::Hasher>>::uninit)
476			.take(pairs.len())
477			.collect::<Vec<_>>();
478
479		PortableBlake3ParallelCompression::<N>::default().parallel_compress(&inputs, &mut out);
480
481		// Invariant: each batched lane equals the scalar two-to-one of the same pair.
482		for (slot, [l, r]) in out.into_iter().zip(pairs) {
483			let expected = Blake3Compression.compress([(*l).into(), (*r).into()]);
484			assert_eq!(unsafe { slot.assume_init() }.as_slice(), expected.as_slice());
485		}
486	}
487
488	#[test]
489	fn test_parallel_compression_boundaries() {
490		// Extreme digests exercise all-zero and all-ones message blocks in every left/right slot.
491		let zero = [0u8; OUT_LEN];
492		let ones = [0xffu8; OUT_LEN];
493		check_parallel_compression::<16>(&[[zero, zero], [ones, ones], [zero, ones], [ones, zero]]);
494
495		// Counts straddling the 16-lane batch boundary: empty, single, full, full+1, multi+partial.
496		let mut rng = StdRng::seed_from_u64(7);
497		for count in [0usize, 1, 15, 16, 17, 33] {
498			let pairs: Vec<[[u8; OUT_LEN]; 2]> = (0..count)
499				.map(|_| {
500					let mut pair = [[0u8; OUT_LEN]; 2];
501					rng.fill_bytes(&mut pair[0]);
502					rng.fill_bytes(&mut pair[1]);
503					pair
504				})
505				.collect();
506			check_parallel_compression::<16>(&pairs);
507		}
508	}
509
510	proptest! {
511		#[test]
512		fn parallel_compression_matches_scalar(
513			pairs in prop::collection::vec(
514				(prop::array::uniform32(any::<u8>()), prop::array::uniform32(any::<u8>())),
515				0..40usize,
516			),
517		) {
518			// The batch path is bit-identical to the scalar reference, at every vectorizer width.
519			let pairs: Vec<[[u8; OUT_LEN]; 2]> = pairs.into_iter().map(|(l, r)| [l, r]).collect();
520			check_parallel_compression::<4>(&pairs);
521			check_parallel_compression::<8>(&pairs);
522			check_parallel_compression::<16>(&pairs);
523		}
524	}
525
526	/// Runs `N` equal-length messages of `len` bytes through the portable batch and pins each lane
527	/// to the scalar reference.
528	fn check_portable_batch<const N: usize>(rng: &mut StdRng, len: usize) {
529		// Fresh random bytes per lane, so lanes don't share a digest by accident.
530		let messages: [Vec<u8>; N] = array::from_fn(|_| {
531			let mut m = vec![0u8; len];
532			rng.fill_bytes(&mut m);
533			m
534		});
535		let refs: [&[u8]; N] = array::from_fn(|i| messages[i].as_slice());
536		let mut out = array::from_fn::<_, N, _>(|_| MaybeUninit::uninit());
537		PortableBlake3MultiDigest::<N>::digest(refs, &mut out);
538
539		// Each lane's output must equal the single-message reference hash of that lane.
540		for (o, message) in out.iter().zip(messages.iter()) {
541			let got = unsafe { o.assume_init_ref() };
542			assert_eq!(got.as_slice(), blake3::hash(message).as_bytes(), "len = {len}, N = {N}");
543		}
544	}
545
546	#[test]
547	fn test_portable_lengths_match_reference() {
548		let mut rng = StdRng::seed_from_u64(0);
549
550		// Invariant: the portable kernel reproduces blake3::hash for any single-chunk length.
551		// Lengths cover every block-structure case within one chunk:
552		// - 0             : the lone empty block.
553		// - 1, 31, 63     : a single sub-block, no full blocks.
554		// - 64, 128, 1024 : exact block multiples.
555		// - 65, 100, 1000 : leading full blocks plus a partial tail.
556		// Three lane widths per length: 4 (NEON), 8, and 16 (the throughput sweet spot).
557		for len in [0, 1, 31, 63, 64, 65, 100, 127, 128, 1000, 1024] {
558			check_portable_batch::<4>(&mut rng, len);
559			check_portable_batch::<8>(&mut rng, len);
560			check_portable_batch::<16>(&mut rng, len);
561		}
562	}
563
564	#[test]
565	fn test_portable_chained_update() {
566		let mut rng = StdRng::seed_from_u64(2);
567		// Four 200-byte messages: three full blocks plus a 8-byte partial tail.
568		let messages: [Vec<u8>; 4] = array::from_fn(|_| {
569			let mut m = vec![0u8; 200];
570			rng.fill_bytes(&mut m);
571			m
572		});
573
574		// Invariant: a message split across two updates hashes the same as one update of the whole.
575		// The 50/150 split lands mid-block, exercising the buffer-fill and deferred-compress paths.
576		let mut hasher = PortableBlake3MultiDigest::<4>::new();
577		hasher.update(array::from_fn(|i| &messages[i][..50]));
578		hasher.update(array::from_fn(|i| &messages[i][50..]));
579		let mut out = array::from_fn::<_, 4, _>(|_| MaybeUninit::uninit());
580		hasher.finalize_into(&mut out);
581
582		for (o, message) in out.iter().zip(messages.iter()) {
583			assert_eq!(unsafe { o.assume_init_ref() }.as_slice(), blake3::hash(message).as_bytes());
584		}
585	}
586
587	#[test]
588	fn test_portable_routing_matches_reference() {
589		let mut rng = StdRng::seed_from_u64(3);
590		// Build 50 leaves of `leaf_len` bytes each, fed as u8 items (BYTE_SIZE = 1).
591		let mut check = |leaf_len: usize| {
592			let leaves: Vec<Vec<u8>> = (0..50)
593				.map(|_| {
594					let mut m = vec![0u8; leaf_len];
595					rng.fill_bytes(&mut m);
596					m
597				})
598				.collect();
599			let digest = PortableBlake3ParallelDigest::<8>::new();
600			let mut results = repeat_with(MaybeUninit::<Output<blake3::Hasher>>::uninit)
601				.take(50)
602				.collect::<Vec<_>>();
603			digest.digest_with_const_len(
604				leaf_len,
605				leaves.par_iter().map(|leaf| leaf.iter().copied()),
606				&mut results,
607			);
608			for (result, leaf) in results.into_iter().zip(&leaves) {
609				let got = unsafe { result.assume_init() };
610				assert_eq!(got.as_slice(), blake3::hash(leaf).as_bytes(), "leaf_len {leaf_len}");
611			}
612		};
613
614		// Invariant: every leaf size reproduces the reference, on the batch or the adapter route.
615		// - 0, 1, 63      : sub-block             -> portable batch.
616		// - 65, 100, 1000 : partial trailing block -> portable batch.
617		// - 64, 1024      : whole blocks           -> portable batch.
618		// - 1025, 2048    : multi-chunk (> 1024)   -> scalar adapter.
619		for leaf_len in [0, 1, 63, 64, 65, 100, 1000, 1024, 1025, 2048] {
620			check(leaf_len);
621		}
622	}
623}