Skip to content

Commit 069f29d

Browse files
authored
feat(bitpacking): Add batched index unpacking (#190)
Add `unpack_indices` and `unchecked_unpack_indices` to extract selected values without a complete 1,024-value unpack. Use `unpack_indices` when the bit width and packed length are compile-time constants. Use `unchecked_unpack_indices` when the bit width is available only at runtime. It dispatches the runtime width once per batch. Both methods write into `MaybeUninit` output. Use `unpack_single` for one selected value. Use full unpack above the recommended threshold. One private `#[inline(always)]` helper implements single-value extraction. Public `unpack_single` keeps an out-of-line boundary. The runtime dispatcher and batch path inline the helper after width dispatch. This keeps width-specific expansion inside library code. ## Performance Native Apple M1 benchmarks use Rust 1.91.0 and `-C target-cpu=native`. At 8 and 32 selected values, batch extraction is 2.0–3.4× faster than repeated `unpack_single` calls. The table gives a conservative dispatch policy across the sampled packed widths. | Physical type | Use batch when | Use full unpack when | | --- | ---: | ---: | | `u8` | `n <= 16` | `n > 16` | | `u16` | `n <= 32` | `n > 32` | | `u32` | `n <= 64` | `n > 64` | | `u64` | `n <= 160` | `n > 160` | The grid covers widths 1, 4, and 7 for `u8`. It covers widths 1, 3, 8, and 15 for `u16`. It covers widths 1, 8, 16, 24, and 31 for `u32`. It covers widths 1, 16, 32, 48, and 63 for `u64`. The limiting measured crossovers were 18–19, 32–34, 68–72, and 176–184. The rounded thresholds leave margin for other hardware. Packed-width effects were non-monotonic, so the policy uses only the physical type and selected-value count. `cargo asm` confirms one runtime-width dispatch per batch. The generated `u16` batch function contains no per-index `unpack_single` calls. ## Code size The release rlib grows from 3,750,536 bytes on `develop` to 3,942,416 bytes, a 5.1% increase. Object text grows from 419,814 bytes to 447,917 bytes, a 6.7% increase. ## Verification Tests cover every integer type and runtime width. They also cover empty, duplicate, unordered, full-block, zero-width, and invalid inputs. 🤖 Generated with [Codex](https://openai.com/codex/) --------- Signed-off-by: Will Manning <will@willmanning.io>
1 parent 6e2aaa6 commit 069f29d

2 files changed

Lines changed: 377 additions & 20 deletions

File tree

benches/bitpacking.rs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::mem::MaybeUninit;
12
use std::mem::size_of;
23

34
use arrayref::{array_mut_ref, array_ref};
@@ -90,6 +91,145 @@ fn unpack_single_16_from_3(bencher: Bencher) {
9091
});
9192
}
9293

94+
const MAX_BENCHMARK_INDICES: usize = 128;
95+
96+
#[derive(Clone, Copy)]
97+
enum IndexDistribution {
98+
Uniform,
99+
Clustered,
100+
UnorderedWithDuplicates,
101+
}
102+
103+
fn benchmark_indices(
104+
distribution: IndexDistribution,
105+
num_indices: usize,
106+
) -> [usize; MAX_BENCHMARK_INDICES] {
107+
assert!(num_indices <= MAX_BENCHMARK_INDICES);
108+
let mut indices = [0; MAX_BENCHMARK_INDICES];
109+
110+
match distribution {
111+
IndexDistribution::Uniform => {
112+
for (position, index) in indices[..num_indices].iter_mut().enumerate() {
113+
*index = position * 1024 / num_indices;
114+
}
115+
}
116+
IndexDistribution::Clustered => {
117+
for (position, index) in indices[..num_indices].iter_mut().enumerate() {
118+
*index = 448 + position;
119+
}
120+
}
121+
IndexDistribution::UnorderedWithDuplicates => {
122+
for position in 0..num_indices {
123+
indices[position] = if position > 0 && position % 4 == 0 {
124+
indices[position - 1]
125+
} else {
126+
(position * 541 + 17) % 1024
127+
};
128+
}
129+
}
130+
}
131+
132+
indices
133+
}
134+
135+
macro_rules! unpack_indices_benchmarks {
136+
($module:ident, $type:ty, $width:expr, $distribution:expr) => {
137+
mod $module {
138+
use super::*;
139+
140+
const WIDTH: usize = $width;
141+
const PACKED_LENGTH: usize = 1024 * WIDTH / <$type>::T;
142+
143+
fn fixture(
144+
num_indices: usize,
145+
) -> ([$type; PACKED_LENGTH], [usize; MAX_BENCHMARK_INDICES]) {
146+
let mask = ((1_u128 << WIDTH) - 1) as $type;
147+
let values =
148+
std::array::from_fn(|index| ((index as $type).wrapping_mul(17)) & mask);
149+
let mut packed = [0; PACKED_LENGTH];
150+
BitPacking::pack::<WIDTH, PACKED_LENGTH>(&values, &mut packed);
151+
let indices = benchmark_indices($distribution, num_indices);
152+
(packed, indices)
153+
}
154+
155+
#[divan::bench(args = [1, 8, 32, 128], sample_size = 10_000)]
156+
fn batched(bencher: Bencher, num_indices: usize) {
157+
let (packed, indices) = fixture(num_indices);
158+
let mut output = [MaybeUninit::<$type>::uninit(); MAX_BENCHMARK_INDICES];
159+
160+
bencher.bench_local(|| {
161+
let packed = black_box(&packed);
162+
let indices = black_box(&indices[..num_indices]);
163+
let output = black_box(&mut output[..num_indices]);
164+
// SAFETY: `packed` contains exactly one packed FastLanes block.
165+
unsafe {
166+
BitPacking::unchecked_unpack_indices(WIDTH, packed, indices, output);
167+
}
168+
black_box(&*output);
169+
});
170+
}
171+
172+
#[divan::bench(args = [1, 8, 32, 128], sample_size = 10_000)]
173+
fn repeated_single(bencher: Bencher, num_indices: usize) {
174+
let (packed, indices) = fixture(num_indices);
175+
let mut output = [MaybeUninit::<$type>::uninit(); MAX_BENCHMARK_INDICES];
176+
177+
bencher.bench_local(|| {
178+
let packed = black_box(&packed);
179+
let indices = black_box(&indices[..num_indices]);
180+
let output = black_box(&mut output[..num_indices]);
181+
for (&index, value) in indices.iter().zip(output.iter_mut()) {
182+
// SAFETY: `packed` contains exactly one packed FastLanes block.
183+
value.write(unsafe {
184+
BitPacking::unchecked_unpack_single(WIDTH, packed, index)
185+
});
186+
}
187+
black_box(&*output);
188+
});
189+
}
190+
191+
#[divan::bench(args = [1, 8, 32, 128], sample_size = 10_000)]
192+
fn full_unpack_then_gather(bencher: Bencher, num_indices: usize) {
193+
let (packed, indices) = fixture(num_indices);
194+
let mut unpacked = [0; 1024];
195+
let mut output = [MaybeUninit::<$type>::uninit(); MAX_BENCHMARK_INDICES];
196+
197+
bencher.bench_local(|| {
198+
let packed = black_box(&packed);
199+
let indices = black_box(&indices[..num_indices]);
200+
let unpacked = black_box(&mut unpacked);
201+
let output = black_box(&mut output[..num_indices]);
202+
// SAFETY: both buffers have the required lengths for `WIDTH`.
203+
unsafe { BitPacking::unchecked_unpack(WIDTH, packed, unpacked) };
204+
for (&index, value) in indices.iter().zip(output.iter_mut()) {
205+
value.write(unpacked[index]);
206+
}
207+
black_box(&*output);
208+
});
209+
}
210+
}
211+
};
212+
}
213+
214+
unpack_indices_benchmarks!(
215+
unpack_indices_u16_width3_uniform,
216+
u16,
217+
3,
218+
IndexDistribution::Uniform
219+
);
220+
unpack_indices_benchmarks!(
221+
unpack_indices_u32_width16_clustered,
222+
u32,
223+
16,
224+
IndexDistribution::Clustered
225+
);
226+
unpack_indices_benchmarks!(
227+
unpack_indices_u64_width63_unordered_duplicates,
228+
u64,
229+
63,
230+
IndexDistribution::UnorderedWithDuplicates
231+
);
232+
93233
#[divan::bench(sample_count = 10000)]
94234
fn throughput_compress(bencher: Bencher) {
95235
const WIDTH: usize = 3;

0 commit comments

Comments
 (0)