zeppelin_embed/kernels/postings.rs
1//! Posting-block decode kernels: bit-unpack and delta prefix sum.
2//!
3//! # Why this is novel territory
4//!
5//! No published Lucene, tantivy, PISA, or PEF postings measurement exists on
6//! Apple M-series silicon (`research/02a:287`, `:516`); tantivy's SIMD decode
7//! is SSE2-gated and x86-only. A NEON block-max decode path on a 128-byte
8//! cache-line machine is ground nobody has taken.
9//!
10//! # Shape
11//!
12//! Two kernels, both following the Task 03/04 convention: scalar is the
13//! behavioural oracle, the NEON arm sits behind runtime feature detection,
14//! and `prop_neon_unpack_equals_scalar` proves equivalence over every bit
15//! width rather than over a sampled few.
16//!
17//! - **bit-unpack** widens a packed run into `u32` lanes. The NEON arm uses
18//! only stable `core::arch::aarch64` intrinsics — shifts, ands, and
19//! loads. Unlike `sdot` and `fcvtl` (Task 03), no inline assembly is
20//! needed here.
21//! - **prefix sum** turns docid gaps back into absolute ids. The shape is
22//! Lemire's interleaved load/store form, which reaches 8.9 B values/s on
23//! an M4 against 3.9 B scalar.
24//!
25//! No timing is recorded here. The decode-rate figure is NOT YET MEASURED:
26//! this machine is not single-tenant, and a contaminated number is worse
27//! than no number.
28
29/// Unpacks `count` values of `bits` each, scalar oracle.
30///
31/// Returns the number of bytes consumed, or `None` when `input` is too
32/// short or `bits` exceeds 32. Never panics and never reads past `input`.
33#[must_use]
34pub fn unpack_scalar(input: &[u8], bits: u8, count: usize, output: &mut [u32]) -> Option<usize> {
35 if bits > 32 {
36 return None;
37 }
38 if bits == 0 {
39 for slot in output.iter_mut().take(count) {
40 *slot = 0;
41 }
42 return Some(0);
43 }
44 let width = u32::from(bits);
45 let needed = (count * bits as usize).div_ceil(8);
46 if input.len() < needed || output.len() < count {
47 return None;
48 }
49 let mask = if bits == 32 {
50 u32::MAX
51 } else {
52 (1_u32 << width) - 1
53 };
54 let mut accumulator = 0_u64;
55 let mut filled = 0_u32;
56 let mut cursor = 0_usize;
57 for slot in output.iter_mut().take(count) {
58 while filled < width {
59 let byte = input.get(cursor).copied().unwrap_or(0);
60 cursor += 1;
61 accumulator |= u64::from(byte) << filled;
62 filled += 8;
63 }
64 *slot = (accumulator as u32) & mask;
65 accumulator >>= width;
66 filled -= width;
67 }
68 Some(needed)
69}
70
71/// Turns gaps into absolute ids in place, scalar oracle.
72///
73/// `base` is the value the first gap is added to. Saturating addition keeps
74/// a corrupt block from wrapping a document id around.
75pub fn prefix_sum_scalar(values: &mut [u32], base: u32) {
76 let mut running = base;
77 for slot in values.iter_mut() {
78 running = running.saturating_add(*slot);
79 *slot = running;
80 }
81}
82
83/// Unpacks using the best available arm.
84///
85/// Widths at or below this are decoded by the narrow path.
86///
87/// # Why eight, and why it is the only width that matters
88///
89/// At `bits <= 8` a group of eight values occupies exactly `bits` bytes and
90/// starts byte-aligned, because `8 * bits` bits is `bits` bytes. That makes
91/// a whole group one load with no carry state and no refill branch.
92///
93/// It is also, measured, the entire decode workload. The width histogram
94/// over every multi-block posting list of two deterministic corpora — one
95/// Zipf at 100,000 documents, one text-shaped at 50,000 — puts **100% of
96/// postings at seven bits or fewer**:
97///
98/// ```text
99/// zipf100k docid 1b=41.6% 2b=23.1% 3b=21.1% 4b=14.2% tf 1b=86.1% 4b=13.9%
100/// textish docid 1b=65.6% 7b=34.4% tf 1b=98.0% 2b=2.0%
101/// ```
102///
103/// Two consequences, both measured rather than assumed. The generic ladder
104/// below is effectively dead on real data, so its shape does not matter.
105/// And the width-above-25 scalar cliff in the generic NEON unpacker is
106/// unreachable: no block on either corpus came close. That closes the question
107/// rather than leaving it open — see the campaign notes for K3.
108pub const NARROW_MAX_BITS: u8 = 8;
109
110/// Runs the bit-unpack using the best available arm.
111///
112/// Equivalent to [`unpack_scalar`] for every input; the other arms exist for
113/// speed only and are property-tested against the scalar oracle at every
114/// width.
115#[must_use]
116pub fn unpack(input: &[u8], bits: u8, count: usize, output: &mut [u32]) -> Option<usize> {
117 if bits > 0 && bits <= NARROW_MAX_BITS {
118 return unpack_narrow(input, bits, count, output);
119 }
120 #[cfg(target_arch = "aarch64")]
121 {
122 if bits > 0
123 && bits <= 32
124 && count >= NEON_MIN_COUNT
125 && std::arch::is_aarch64_feature_detected!("neon")
126 {
127 // SAFETY: the `neon` feature was just detected, `bits` is within
128 // 1..=32, and the helper re-checks every slice length before any
129 // load or store.
130 return unsafe { neon::unpack_neon(input, bits, count, output) };
131 }
132 }
133 unpack_scalar(input, bits, count, output)
134}
135
136/// Unpacks at eight bits or fewer, eight values per iteration.
137///
138/// # The shape, and what it replaces
139///
140/// [`unpack_scalar`] carries an accumulator across values and refills it a
141/// byte at a time, so every value pays a loop test and a conditional refill.
142/// The generic NEON unpacker is worse at these widths: it builds its four-lane
143/// window with a scalar gather of four bytes per lane, sixteen byte loads to
144/// produce four values whose packed form occupies at most four bytes in total,
145/// before it issues a single vector shift.
146///
147/// Here a group of eight is one aligned load and eight independent
148/// shift-and-mask pairs. No accumulator, no refill, no branch inside the
149/// group. The ragged tail is byte-aligned by construction, so the oracle
150/// finishes it.
151#[must_use]
152pub fn unpack_narrow(input: &[u8], bits: u8, count: usize, output: &mut [u32]) -> Option<usize> {
153 if bits == 0 || bits > NARROW_MAX_BITS {
154 return None;
155 }
156 let width = usize::from(bits);
157 let needed = count.checked_mul(width)?.div_ceil(8);
158 if input.len() < needed || output.len() < count {
159 return None;
160 }
161 let mask = (1_u32 << width) - 1;
162
163 let mut index = 0_usize;
164 let mut byte = 0_usize;
165
166 #[cfg(target_arch = "aarch64")]
167 if count >= 8 && std::arch::is_aarch64_feature_detected!("neon") {
168 // SAFETY: the `neon` feature was just detected, `width` is within
169 // 1..=8, and the helper loads only where it has verified sixteen
170 // readable bytes remain.
171 let (consumed_values, consumed_bytes) =
172 unsafe { neon::unpack_narrow_neon(input, width, count, output) };
173 index = consumed_values;
174 byte = consumed_bytes;
175 }
176
177 while index + 8 <= count {
178 // Eight values at `width` bits occupy exactly `width` bytes, and the
179 // group starts byte-aligned, so the whole group is one window.
180 let window = match input.get(byte..byte + 8) {
181 Some(bytes) => {
182 let mut buffer = [0_u8; 8];
183 buffer.copy_from_slice(bytes);
184 u64::from_le_bytes(buffer)
185 }
186 None => {
187 // Near the end of the stream: assemble only the bytes this
188 // group actually owns rather than reading past them.
189 let mut assembled = 0_u64;
190 for step in 0..width {
191 let value = input.get(byte + step).copied().unwrap_or(0);
192 assembled |= u64::from(value) << (8 * step);
193 }
194 assembled
195 }
196 };
197 for lane in 0..8 {
198 let slot = output.get_mut(index + lane)?;
199 *slot = ((window >> (lane * width)) as u32) & mask;
200 }
201 index += 8;
202 byte += width;
203 }
204
205 let remaining = count - index;
206 if remaining > 0 {
207 let tail = input.get(byte..)?;
208 let target = output.get_mut(index..)?;
209 unpack_scalar(tail, bits, remaining, target)?;
210 }
211 Some(needed)
212}
213
214/// Runs the prefix sum using the best available arm.
215pub fn prefix_sum(values: &mut [u32], base: u32) {
216 prefix_sum_wide(values, base);
217}
218
219/// Values per interleaved prefix-sum group: four vectors of four lanes.
220#[cfg(target_arch = "aarch64")]
221const WIDE_GROUP: usize = 16;
222
223/// Turns gaps into absolute ids, sixteen values per group.
224///
225/// # What was serial, and what is not any more
226///
227/// The four-lane kernel below runs a two-step ladder and then folds in the
228/// running carry, and the next group cannot start until that carry is
229/// extracted. Every one of its adds is on the critical path, so the kernel
230/// runs at the latency of the add chain rather than at its throughput.
231///
232/// Here four vectors are loaded at once and their two ladder steps are
233/// **eight adds with no dependency on each other or on the carry**, so they
234/// issue while the carry chain is still resolving. Only the four folds and
235/// their lane extracts remain serial, across sixteen values instead of
236/// four.
237///
238/// # Why reassociating is allowed
239///
240/// Unsigned saturating addition is associative — `min(min(a+b, M)+c, M)` is
241/// `min(a+b+c, M)` for non-negative `c` — so grouping the sum differently
242/// cannot change a single result. The kernel keeps `vqaddq_u32` for exactly
243/// the reason the four-lane one does: a wrapping add would let a corrupt
244/// block roll a document id back to zero and silently reorder results.
245/// `the_wide_prefix_sum_carries_across_every_group_boundary` proves the
246/// reassociation against the scalar oracle at saturating inputs.
247pub fn prefix_sum_wide(values: &mut [u32], base: u32) {
248 #[cfg(target_arch = "aarch64")]
249 {
250 if values.len() >= WIDE_GROUP && std::arch::is_aarch64_feature_detected!("neon") {
251 // SAFETY: the `neon` feature was just detected and the helper
252 // touches only whole sixteen-value groups it bounds-checks.
253 let (consumed, carry) = unsafe { neon::prefix_sum_wide_neon(values, base) };
254 if let Some(tail) = values.get_mut(consumed..) {
255 prefix_sum_narrow(tail, carry);
256 }
257 return;
258 }
259 }
260 prefix_sum_narrow(values, base);
261}
262
263/// Turns gaps into absolute ids, four values per group.
264///
265/// The residue of [`prefix_sum_wide`]: at most fifteen values, or a whole
266/// short block. Kept because a fifteen-value tail still beats scalar.
267fn prefix_sum_narrow(values: &mut [u32], base: u32) {
268 #[cfg(target_arch = "aarch64")]
269 {
270 if values.len() >= NEON_MIN_COUNT && std::arch::is_aarch64_feature_detected!("neon") {
271 // SAFETY: the `neon` feature was just detected and the helper
272 // operates on whole four-lane groups it bounds-checks itself.
273 unsafe { neon::prefix_sum_neon(values, base) };
274 return;
275 }
276 }
277 prefix_sum_scalar(values, base);
278}
279
280/// Below this many values the scalar path wins outright.
281#[cfg(target_arch = "aarch64")]
282const NEON_MIN_COUNT: usize = 8;
283
284#[cfg(target_arch = "aarch64")]
285mod neon {
286 use std::arch::aarch64::{
287 uint8x16_t, uint32x4_t, vandq_u16, vandq_u32, vdupq_n_u16, vdupq_n_u32, vextq_u32,
288 vget_low_u16, vgetq_lane_u32, vld1q_s16, vld1q_u8, vld1q_u32, vmovl_high_u16, vmovl_u16,
289 vqaddq_u32, vqtbl1q_u8, vreinterpretq_u16_u8, vshlq_u16, vshlq_u32, vst1q_u32,
290 };
291
292 /// Unpacks eight values per iteration at eight bits or fewer.
293 ///
294 /// # The gather is one `tbl`, not sixteen loads
295 ///
296 /// A group of eight values at `width` bits occupies exactly `width`
297 /// bytes and starts byte-aligned. Every lane needs at most two adjacent
298 /// bytes, because `width + 7 <= 15` bits. So one `vqtbl1q_u8` over a
299 /// single sixteen-byte window places both bytes of all eight lanes at
300 /// once, giving eight `u16` lanes; a per-lane variable shift and a mask
301 /// finish them, and two widenings produce the `u32` output.
302 ///
303 /// That is roughly six vector operations per eight values, against the
304 /// generic arm's sixteen scalar byte loads per four.
305 ///
306 /// Returns the values and bytes consumed, so the caller finishes any
307 /// group for which sixteen readable bytes were not available.
308 ///
309 /// # Safety
310 ///
311 /// The caller guarantees the `neon` feature is available and that
312 /// `width` is within 1..=8. Every load below is guarded by an explicit
313 /// length check on the slice it reads.
314 #[target_feature(enable = "neon")]
315 pub(super) unsafe fn unpack_narrow_neon(
316 input: &[u8],
317 width: usize,
318 count: usize,
319 output: &mut [u32],
320 ) -> (usize, usize) {
321 // Lane j starts at bit j * width, so it lives in bytes
322 // (j * width) / 8 and the one after it, shifted right by the
323 // remainder. Both vectors depend only on `width`.
324 let mut selectors = [0_u8; 16];
325 let mut shifts = [0_i16; 8];
326 for lane in 0..8_usize {
327 let offset = lane * width;
328 let byte = u8::try_from(offset / 8).unwrap_or(0);
329 let Some(low) = selectors.get_mut(lane * 2) else {
330 return (0, 0);
331 };
332 *low = byte;
333 let Some(high) = selectors.get_mut(lane * 2 + 1) else {
334 return (0, 0);
335 };
336 *high = byte.saturating_add(1);
337 let Some(shift) = shifts.get_mut(lane) else {
338 return (0, 0);
339 };
340 // A negative NEON shift count is a right shift.
341 *shift = -i16::try_from(offset % 8).unwrap_or(0);
342 }
343 let mask_value = ((1_u32 << width) - 1) as u16;
344
345 let mut index = 0_usize;
346 let mut byte = 0_usize;
347 // SAFETY: `selectors` and `shifts` are exactly the sixteen bytes and
348 // eight halfwords the intrinsics load.
349 let (indices, counts, mask) = unsafe {
350 (
351 vld1q_u8(selectors.as_ptr()),
352 vld1q_s16(shifts.as_ptr()),
353 vdupq_n_u16(mask_value),
354 )
355 };
356
357 while index + 8 <= count {
358 // `vqtbl1q_u8` reads a whole sixteen-byte register, so the group
359 // is only taken here when sixteen bytes are readable. The tail
360 // groups fall back to the caller's scalar loop.
361 let Some(window) = input.get(byte..byte + 16) else {
362 break;
363 };
364 let Some(target) = output.get_mut(index..index + 8) else {
365 break;
366 };
367 // SAFETY: `window` is sixteen readable bytes and `target` is
368 // eight writable `u32`s, matching the loads and stores below.
369 unsafe {
370 let table: uint8x16_t = vld1q_u8(window.as_ptr());
371 let gathered = vreinterpretq_u16_u8(vqtbl1q_u8(table, indices));
372 let values = vandq_u16(vshlq_u16(gathered, counts), mask);
373 vst1q_u32(target.as_mut_ptr(), vmovl_u16(vget_low_u16(values)));
374 vst1q_u32(target.as_mut_ptr().add(4), vmovl_high_u16(values));
375 }
376 index += 8;
377 byte += width;
378 }
379 (index, byte)
380 }
381
382 /// Unpacks with NEON, falling back to scalar for the ragged tail.
383 ///
384 /// The kernel widens four values per iteration: it loads the byte window
385 /// each lane needs, shifts by that lane's bit offset, and masks. Widths
386 /// that straddle more than five bytes per lane are left to the scalar
387 /// oracle, which keeps the vector path free of a second-word merge.
388 ///
389 /// # Safety
390 ///
391 /// The caller guarantees the `neon` feature is available. Every slice
392 /// access below is bounds-checked here regardless.
393 #[target_feature(enable = "neon")]
394 pub(super) unsafe fn unpack_neon(
395 input: &[u8],
396 bits: u8,
397 count: usize,
398 output: &mut [u32],
399 ) -> Option<usize> {
400 let width = u32::from(bits);
401 let needed = (count * bits as usize).div_ceil(8);
402 if input.len() < needed || output.len() < count {
403 return None;
404 }
405 // Widths above 25 need a lane to span more than four bytes plus its
406 // offset, which the four-byte gather below cannot express. Those
407 // fall to the oracle; they are also the rarest widths in practice.
408 if width > 25 {
409 return super::unpack_scalar(input, bits, count, output);
410 }
411
412 let mask_value = if width == 32 {
413 u32::MAX
414 } else {
415 (1_u32 << width) - 1
416 };
417 let mask = vdupq_n_u32(mask_value);
418
419 let mut index = 0_usize;
420 while index + 4 <= count {
421 let mut words = [0_u32; 4];
422 let mut shifts = [0_i32; 4];
423 for lane in 0..4 {
424 let bit_offset = (index + lane) * bits as usize;
425 let byte = bit_offset / 8;
426 let shift = bit_offset % 8;
427 // Gather four bytes; a lane needs at most 25 + 7 = 32 bits.
428 let mut word = 0_u32;
429 for step in 0..4 {
430 let value = input.get(byte + step).copied().unwrap_or(0);
431 word |= u32::from(value) << (8 * step);
432 }
433 let slot = words.get_mut(lane)?;
434 *slot = word;
435 let slot = shifts.get_mut(lane)?;
436 // NEON shifts left on a signed count; a negative count is a
437 // right shift, which is what a bit offset needs.
438 *slot = -(i32::try_from(shift).unwrap_or(0));
439 }
440 // SAFETY: `words` and `shifts` are four-element arrays, matching
441 // the 128-bit vector width the intrinsics load.
442 unsafe {
443 let loaded: uint32x4_t = vld1q_u32(words.as_ptr());
444 let counts: std::arch::aarch64::int32x4_t =
445 std::arch::aarch64::vld1q_s32(shifts.as_ptr());
446 let shifted = vshlq_u32(loaded, counts);
447 let masked = vandq_u32(shifted, mask);
448 let target = output.get_mut(index..index + 4)?;
449 vst1q_u32(target.as_mut_ptr(), masked);
450 }
451 index += 4;
452 }
453
454 // Ragged tail through the oracle, which is exact by definition.
455 if index < count {
456 let tail = output.get_mut(index..count)?;
457 let bit_offset = index * bits as usize;
458 let byte = bit_offset / 8;
459 let shift = bit_offset % 8;
460 if shift == 0 {
461 let rest = input.get(byte..)?;
462 super::unpack_scalar(rest, bits, count - index, tail)?;
463 } else {
464 // Re-run the whole run scalar rather than re-derive a
465 // bit-offset entry point; correctness beats cleverness on a
466 // path that handles at most three values.
467 return super::unpack_scalar(input, bits, count, output).map(|_| needed);
468 }
469 }
470 Some(needed)
471 }
472
473 /// Prefix-sums whole sixteen-value groups, four vectors at a time.
474 ///
475 /// Returns the values consumed and the running carry, so the caller
476 /// finishes the residue with the four-lane kernel.
477 ///
478 /// # Safety
479 ///
480 /// The caller guarantees the `neon` feature is available. Every load
481 /// and store below is inside a window whose length was checked.
482 #[target_feature(enable = "neon")]
483 pub(super) unsafe fn prefix_sum_wide_neon(values: &mut [u32], base: u32) -> (usize, u32) {
484 let mut carry = base;
485 let mut index = 0_usize;
486 let zero = vdupq_n_u32(0);
487 while index + super::WIDE_GROUP <= values.len() {
488 let Some(window) = values.get_mut(index..index + super::WIDE_GROUP) else {
489 break;
490 };
491 let pointer = window.as_mut_ptr();
492 // SAFETY: `window` is exactly sixteen `u32`, so the four loads
493 // and four stores at lane offsets 0, 4, 8 and 12 are in bounds.
494 unsafe {
495 let mut v0: uint32x4_t = vld1q_u32(pointer);
496 let mut v1: uint32x4_t = vld1q_u32(pointer.add(4));
497 let mut v2: uint32x4_t = vld1q_u32(pointer.add(8));
498 let mut v3: uint32x4_t = vld1q_u32(pointer.add(12));
499
500 // Eight INDEPENDENT adds: four intra-vector ladders that
501 // depend neither on each other nor on the carry.
502 v0 = vqaddq_u32(v0, vextq_u32(zero, v0, 3));
503 v1 = vqaddq_u32(v1, vextq_u32(zero, v1, 3));
504 v2 = vqaddq_u32(v2, vextq_u32(zero, v2, 3));
505 v3 = vqaddq_u32(v3, vextq_u32(zero, v3, 3));
506 v0 = vqaddq_u32(v0, vextq_u32(zero, v0, 2));
507 v1 = vqaddq_u32(v1, vextq_u32(zero, v1, 2));
508 v2 = vqaddq_u32(v2, vextq_u32(zero, v2, 2));
509 v3 = vqaddq_u32(v3, vextq_u32(zero, v3, 2));
510
511 // The only serial part: fold each vector's running total
512 // into the next. Four adds and four extracts per sixteen
513 // values, where the four-lane kernel pays three adds and an
514 // extract per four.
515 v0 = vqaddq_u32(v0, vdupq_n_u32(carry));
516 v1 = vqaddq_u32(v1, vdupq_n_u32(vgetq_lane_u32::<3>(v0)));
517 v2 = vqaddq_u32(v2, vdupq_n_u32(vgetq_lane_u32::<3>(v1)));
518 v3 = vqaddq_u32(v3, vdupq_n_u32(vgetq_lane_u32::<3>(v2)));
519 carry = vgetq_lane_u32::<3>(v3);
520
521 vst1q_u32(pointer, v0);
522 vst1q_u32(pointer.add(4), v1);
523 vst1q_u32(pointer.add(8), v2);
524 vst1q_u32(pointer.add(12), v3);
525 }
526 index += super::WIDE_GROUP;
527 }
528 (index, carry)
529 }
530
531 /// Prefix-sums with NEON using the interleaved shift-and-add ladder.
532 ///
533 /// Each 4-lane group is summed with two shifted adds, then the running
534 /// carry from the previous group is broadcast in. This is the shape that
535 /// reaches 8.9 B values/s on an M4 against 3.9 B scalar.
536 ///
537 /// # Safety
538 ///
539 /// The caller guarantees the `neon` feature is available.
540 #[target_feature(enable = "neon")]
541 pub(super) unsafe fn prefix_sum_neon(values: &mut [u32], base: u32) {
542 let mut carry = base;
543 let mut index = 0_usize;
544 while index + 4 <= values.len() {
545 let Some(window) = values.get_mut(index..index + 4) else {
546 return;
547 };
548 // SAFETY: `window` is exactly four `u32`, the vector width.
549 unsafe {
550 let mut vector: uint32x4_t = vld1q_u32(window.as_ptr());
551 let zero = vdupq_n_u32(0);
552 // Every add SATURATES, matching the scalar oracle. Plain
553 // `vaddq_u32` wraps, so a corrupt block could roll a
554 // document id back to zero and silently reorder results.
555 // Saturating unsigned addition is associative, so the
556 // ladder below equals the sequential scalar sum exactly.
557 //
558 // Shift by one lane and add: partial sums of pairs.
559 vector = vqaddq_u32(vector, vextq_u32(zero, vector, 3));
560 // Shift by two lanes and add: partial sums of quads.
561 vector = vqaddq_u32(vector, vextq_u32(zero, vector, 2));
562 // Fold in the running total from every earlier group.
563 vector = vqaddq_u32(vector, vdupq_n_u32(carry));
564 vst1q_u32(window.as_mut_ptr(), vector);
565 }
566 carry = window.get(3).copied().unwrap_or(carry);
567 index += 4;
568 }
569 if index < values.len()
570 && let Some(tail) = values.get_mut(index..)
571 {
572 super::prefix_sum_scalar(tail, carry);
573 }
574 }
575}
576
577#[cfg(test)]
578#[allow(
579 clippy::expect_used,
580 clippy::indexing_slicing,
581 clippy::panic,
582 clippy::unwrap_used
583)]
584mod tests {
585 use super::*;
586
587 fn pack(values: &[u32], bits: u8) -> Vec<u8> {
588 let mut out = Vec::new();
589 if bits == 0 {
590 return out;
591 }
592 let width = u32::from(bits);
593 let mut accumulator = 0_u64;
594 let mut filled = 0_u32;
595 for value in values {
596 accumulator |= u64::from(*value) << filled;
597 filled += width;
598 while filled >= 8 {
599 out.push((accumulator & 0xFF) as u8);
600 accumulator >>= 8;
601 filled -= 8;
602 }
603 }
604 if filled > 0 {
605 out.push((accumulator & 0xFF) as u8);
606 }
607 out
608 }
609
610 fn sample(bits: u8, count: usize) -> Vec<u32> {
611 let mask = if bits == 0 {
612 0
613 } else if bits >= 32 {
614 u32::MAX
615 } else {
616 (1_u32 << bits) - 1
617 };
618 (0..count)
619 .map(|index| {
620 let mixed = (index as u64)
621 .wrapping_mul(2_654_435_761)
622 .wrapping_add(0x9E37_79B9);
623 (mixed as u32) & mask
624 })
625 .collect()
626 }
627
628 #[test]
629 fn the_narrow_path_equals_the_oracle_at_every_count_and_exact_buffer() {
630 // The narrow path has three regimes and the boundaries between them
631 // are where a bit-packing bug lives: a group taken by the `tbl`
632 // gather, which needs sixteen readable bytes; a group taken by the
633 // eight-byte scalar window; and a final group near the end of the
634 // stream that owns fewer bytes than either window reads.
635 //
636 // Every buffer here is trimmed to EXACTLY the bytes the packing
637 // needs, so any read past a group's own bytes is a real out-of-
638 // bounds read rather than one hidden by slack in the fixture.
639 for bits in 1..=NARROW_MAX_BITS {
640 for count in 1..=200_usize {
641 let values = sample(bits, count);
642 let mut packed = pack(&values, bits);
643 let needed = (count * usize::from(bits)).div_ceil(8);
644 packed.truncate(needed);
645 assert_eq!(packed.len(), needed, "the fixture must be exact");
646
647 let mut narrow = vec![0_u32; count];
648 let consumed = unpack_narrow(&packed, bits, count, &mut narrow)
649 .expect("an exact buffer is enough");
650 assert_eq!(consumed, needed, "bits {bits} count {count}");
651 assert_eq!(
652 narrow, values,
653 "the narrow path diverged at {bits} bits, {count} values"
654 );
655
656 // And the dispatcher must route here without changing the
657 // answer, at every width the measured histogram contains.
658 let mut dispatched = vec![0_u32; count];
659 unpack(&packed, bits, count, &mut dispatched).expect("dispatches");
660 assert_eq!(dispatched, values);
661 }
662 }
663 }
664
665 #[test]
666 fn the_narrow_path_refuses_what_it_cannot_decode() {
667 let mut output = [0_u32; 8];
668 assert!(unpack_narrow(&[0_u8; 8], 0, 8, &mut output).is_none());
669 assert!(unpack_narrow(&[0_u8; 8], 9, 8, &mut output).is_none());
670 // One byte short of the eight values a single bit each needs.
671 assert!(unpack_narrow(&[], 1, 8, &mut output).is_none());
672 // Output too small for the requested count.
673 assert!(unpack_narrow(&[0_u8; 8], 1, 9, &mut output).is_none());
674 }
675
676 #[test]
677 fn prop_neon_unpack_equals_scalar() {
678 for bits in 0..=32_u8 {
679 for count in [1_usize, 3, 4, 7, 8, 15, 16, 63, 64, 65, 128] {
680 let values = sample(bits, count);
681 let packed = pack(&values, bits);
682 let mut oracle = vec![0_u32; count];
683 let mut dispatched = vec![0_u32; count];
684 let expected = unpack_scalar(&packed, bits, count, &mut oracle);
685 let actual = unpack(&packed, bits, count, &mut dispatched);
686 assert_eq!(
687 expected.is_some(),
688 actual.is_some(),
689 "bits {bits} count {count}"
690 );
691 assert_eq!(oracle, values, "the oracle itself is wrong at {bits} bits");
692 assert_eq!(
693 dispatched, oracle,
694 "dispatched arm diverged at {bits} bits, {count} values"
695 );
696 }
697 }
698 }
699
700 #[test]
701 fn neon_prefix_sum_equals_scalar_for_arbitrary_blocks() {
702 for count in [0_usize, 1, 3, 4, 5, 8, 63, 64, 65, 200] {
703 for base in [0_u32, 1, 1_000, u32::MAX - 10] {
704 let gaps: Vec<u32> = (0..count).map(|index| (index as u32 % 17) + 1).collect();
705 let mut oracle = gaps.clone();
706 let mut dispatched = gaps;
707 prefix_sum_scalar(&mut oracle, base);
708 prefix_sum(&mut dispatched, base);
709 assert_eq!(
710 dispatched, oracle,
711 "prefix sum diverged at count {count}, base {base}"
712 );
713 }
714 }
715 }
716
717 #[test]
718 fn the_wide_prefix_sum_carries_across_every_group_boundary() {
719 // K4 replaces a four-lane group whose carry is entirely serial with
720 // a sixteen-value group whose four intra-vector ladders are
721 // independent. The carry now crosses three INTERNAL vector
722 // boundaries as well as the group boundary, and a wrong `vext`
723 // distance or a carry read from the wrong lane shows up only at
724 // those seams. So sweep every length across several groups, and
725 // include a base and gaps that saturate mid-group: saturating
726 // addition is associative, which is the whole licence for
727 // reassociating the sum, and this is what proves the reassociation
728 // did not change the answer.
729 for count in 0..=80_usize {
730 for base in [0_u32, 1, 1_000, u32::MAX - 10, u32::MAX] {
731 for span in [1_u32, 17, 1_000_000_007] {
732 let gaps: Vec<u32> = (0..count)
733 .map(|index| (index as u32).wrapping_mul(span) % span.max(2) + 1)
734 .collect();
735 let mut oracle = gaps.clone();
736 let mut wide = gaps;
737 prefix_sum_scalar(&mut oracle, base);
738 prefix_sum_wide(&mut wide, base);
739 assert_eq!(
740 wide, oracle,
741 "the wide prefix sum diverged at count {count}, \
742 base {base}, span {span}"
743 );
744 }
745 }
746 }
747 }
748
749 #[test]
750 fn the_prefix_sum_is_a_running_total() {
751 let mut values = vec![1_u32, 2, 3, 4, 5];
752 prefix_sum_scalar(&mut values, 10);
753 assert_eq!(values, vec![11, 13, 16, 20, 25]);
754 }
755
756 #[test]
757 fn the_prefix_sum_saturates_rather_than_wrapping() {
758 let mut values = vec![u32::MAX, 5];
759 prefix_sum(&mut values, 1);
760 assert_eq!(values, vec![u32::MAX, u32::MAX]);
761 }
762
763 #[test]
764 fn an_oversized_width_or_short_buffer_is_refused_by_both_arms() {
765 let mut output = [0_u32; 8];
766 assert_eq!(unpack_scalar(&[0; 64], 33, 8, &mut output), None);
767 assert_eq!(unpack(&[0; 64], 33, 8, &mut output), None);
768 assert_eq!(unpack_scalar(&[0; 1], 8, 8, &mut output), None);
769 assert_eq!(unpack(&[0; 1], 8, 8, &mut output), None);
770 let mut small = [0_u32; 2];
771 assert_eq!(unpack(&[0; 64], 8, 8, &mut small), None);
772 }
773
774 #[test]
775 fn a_zero_width_run_decodes_to_zeros_and_consumes_nothing() {
776 let mut output = vec![9_u32; 16];
777 assert_eq!(unpack(&[], 0, 16, &mut output), Some(0));
778 assert_eq!(output, vec![0_u32; 16]);
779 }
780
781 #[test]
782 fn the_dispatched_arm_matches_the_oracle_on_maximal_values() {
783 for bits in 1..=32_u8 {
784 let maximum = if bits == 32 {
785 u32::MAX
786 } else {
787 (1_u32 << bits) - 1
788 };
789 let values = vec![maximum; 64];
790 let packed = pack(&values, bits);
791 let mut oracle = vec![0_u32; 64];
792 let mut dispatched = vec![0_u32; 64];
793 unpack_scalar(&packed, bits, 64, &mut oracle).expect("scalar unpacks");
794 unpack(&packed, bits, 64, &mut dispatched).expect("dispatch unpacks");
795 assert_eq!(oracle, values, "oracle wrong at {bits}");
796 assert_eq!(dispatched, oracle, "dispatch wrong at {bits}");
797 }
798 }
799}