1#[cfg(feature = "native")]
16use rayon::prelude::*;
17const TERM_DEGREE_VALUE_BYTES: usize = std::mem::size_of::<[u32; 2]>();
18const CANDIDATE_ENTRY_BYTES: usize = std::mem::size_of::<(usize, u32)>();
19const PARALLEL_BP_MIN_ENTITIES: usize = 1_048_576;
23const MIN_RELATIVE_OBJECTIVE_IMPROVEMENT: f64 = 1e-6;
24const MIN_OBJECTIVE_ITERATIONS: usize = 4;
25const OBJECTIVE_STALL_ITERATIONS: usize = 2;
26
27fn term_degree_bytes(num_terms: usize) -> usize {
28 let bitmap_words = num_terms.div_ceil(64);
29 num_terms
30 .saturating_mul(TERM_DEGREE_VALUE_BYTES)
31 .saturating_add(bitmap_words.saturating_mul(std::mem::size_of::<u64>()))
32 .saturating_add(bitmap_words.saturating_mul(std::mem::size_of::<u32>()))
36}
37
38#[cfg(feature = "native")]
43trait FrequencyParallelSafe: Sync {}
44#[cfg(feature = "native")]
45impl<T: Sync + ?Sized> FrequencyParallelSafe for T {}
46
47#[cfg(not(feature = "native"))]
48trait FrequencyParallelSafe {}
49#[cfg(not(feature = "native"))]
50impl<T: ?Sized> FrequencyParallelSafe for T {}
51
52fn count_frequencies_bounded<T: FrequencyParallelSafe>(
53 items: &[T],
54 num_terms: usize,
55 available_bytes: usize,
56 count_item: impl Fn(&T, &mut [u32]) + FrequencyParallelSafe,
57) -> Option<Vec<u32>> {
58 if num_terms == 0 {
59 return Some(Vec::new());
60 }
61 let table_bytes = num_terms
62 .checked_mul(std::mem::size_of::<u32>())?
63 .checked_add(std::mem::size_of::<Vec<u32>>())?;
64 let affordable_tables = available_bytes.checked_div(table_bytes)?;
65 if affordable_tables == 0 {
66 return None;
67 }
68
69 #[cfg(feature = "native")]
70 {
71 let lanes = affordable_tables
72 .min(rayon::current_num_threads().max(1))
73 .min(items.len().max(1));
74 if lanes > 1 {
75 let chunk_len = items.len().div_ceil(lanes);
76 return items
77 .par_chunks(chunk_len)
78 .map(|chunk| {
79 let mut counts = vec![0u32; num_terms];
80 for item in chunk {
81 count_item(item, &mut counts);
82 }
83 counts
84 })
85 .reduce_with(|mut left, right| {
86 for (total, count) in left.iter_mut().zip(right) {
87 *total = total.saturating_add(count);
88 }
89 left
90 });
91 }
92 }
93
94 let mut counts = vec![0u32; num_terms];
95 for item in items {
96 count_item(item, &mut counts);
97 }
98 Some(counts)
99}
100
101fn select_frequency_candidates(
104 frequencies: &[u32],
105 min_frequency: usize,
106 max_frequency: usize,
107 candidate_budget_bytes: usize,
108) -> (Vec<(u32, usize)>, bool) {
109 let eligible_count = frequencies
110 .iter()
111 .filter(|&&frequency| {
112 let frequency = frequency as usize;
113 frequency >= min_frequency && frequency <= max_frequency
114 })
115 .count();
116 let capacity = candidate_budget_bytes
117 .checked_div(CANDIDATE_ENTRY_BYTES)
118 .unwrap_or(0)
119 .min(eligible_count);
120 let mut candidates = std::collections::BinaryHeap::with_capacity(capacity);
121 for (term_id, &frequency) in frequencies.iter().enumerate() {
122 let frequency = frequency as usize;
123 if frequency < min_frequency || frequency > max_frequency {
124 continue;
125 }
126 let candidate = (frequency, term_id as u32);
127 if candidates.len() < capacity {
128 candidates.push(candidate);
129 } else if capacity > 0 && candidate < *candidates.peek().unwrap() {
130 candidates.pop();
131 candidates.push(candidate);
132 }
133 }
134 let selected = candidates
135 .into_vec()
136 .into_iter()
137 .map(|(frequency, term_id)| (term_id, frequency))
138 .collect();
139 (selected, capacity < eligible_count)
140}
141
142struct CandidateFit {
143 estimated_bytes: usize,
144 retained_postings: usize,
145 dropped: usize,
146}
147
148fn fit_candidates_to_budget(
152 candidates: &mut Vec<(u32, usize)>,
153 fixed_bytes: usize,
154 memory_budget_bytes: usize,
155) -> CandidateFit {
156 let total_postings = candidates.iter().fold(0usize, |total, (_, frequency)| {
157 total.saturating_add(*frequency)
158 });
159 let estimated_bytes = total_postings
160 .saturating_mul(std::mem::size_of::<u32>())
161 .saturating_add(fixed_bytes)
162 .saturating_add(candidates.len().saturating_mul(CANDIDATE_ENTRY_BYTES))
163 .saturating_add(term_degree_bytes(candidates.len()));
164 if estimated_bytes <= memory_budget_bytes || candidates.is_empty() {
165 return CandidateFit {
166 estimated_bytes,
167 retained_postings: total_postings,
168 dropped: 0,
169 };
170 }
171
172 candidates.sort_by_key(|&(_, frequency)| frequency);
173 let mut used_bytes = fixed_bytes;
174 let mut retained_postings = 0usize;
175 let mut keep = 0usize;
176 for &(_, frequency) in candidates.iter() {
177 let term_bytes = frequency
178 .saturating_mul(std::mem::size_of::<u32>())
179 .saturating_add(TERM_DEGREE_VALUE_BYTES + 1)
180 .saturating_add(CANDIDATE_ENTRY_BYTES);
181 if term_bytes > memory_budget_bytes.saturating_sub(used_bytes) {
182 break;
183 }
184 used_bytes = used_bytes.saturating_add(term_bytes);
185 retained_postings = retained_postings.saturating_add(frequency);
186 keep += 1;
187 }
188 let dropped = candidates.len() - keep;
189 candidates.truncate(keep);
190 candidates.shrink_to_fit();
193 CandidateFit {
194 estimated_bytes,
195 retained_postings,
196 dropped,
197 }
198}
199
200fn parallel_bisect_lanes(
201 memory_budget_bytes: usize,
202 non_degree_bytes: usize,
203 num_terms: usize,
204) -> usize {
205 let per_node = term_degree_bytes(num_terms).max(1);
206 let affordable_nodes = memory_budget_bytes
207 .saturating_sub(non_degree_bytes)
208 .checked_div(per_node)
209 .unwrap_or(0)
210 .max(1);
211 #[cfg(feature = "native")]
212 let worker_limit = rayon::current_num_threads().max(1);
213 #[cfg(not(feature = "native"))]
214 let worker_limit = 1usize;
215 affordable_nodes.min(worker_limit)
216}
217
218struct TermDegrees {
226 values: Vec<std::mem::MaybeUninit<[u32; 2]>>,
227 initialized: Vec<u64>,
228 touched_words: Vec<u32>,
229}
230
231impl TermDegrees {
232 fn new(num_terms: usize) -> Self {
233 let bitmap_words = num_terms.div_ceil(64);
234 let mut values = Vec::with_capacity(num_terms);
235 values.resize_with(num_terms, std::mem::MaybeUninit::uninit);
236 Self {
237 values,
238 initialized: vec![0; bitmap_words],
239 touched_words: Vec::with_capacity(bitmap_words),
240 }
241 }
242
243 fn reset(&mut self) {
246 for word in self.touched_words.drain(..) {
247 self.initialized[word as usize] = 0;
248 }
249 }
250
251 fn sort_touched_words(&mut self) {
252 self.touched_words.sort_unstable();
253 }
254
255 #[inline]
256 fn entry_mut(&mut self, term: usize) -> &mut [u32; 2] {
257 let word = term / 64;
258 let mask = 1u64 << (term % 64);
259 if self.initialized[word] & mask == 0 {
260 if self.initialized[word] == 0 {
261 self.touched_words.push(word as u32);
262 }
263 self.values[term].write([0, 0]);
264 self.initialized[word] |= mask;
265 }
266 unsafe { self.values[term].assume_init_mut() }
268 }
269
270 #[inline]
271 fn get(&self, term: usize) -> [u32; 2] {
272 let word = term / 64;
273 let mask = 1u64 << (term % 64);
274 if self.initialized[word] & mask == 0 {
275 return [0, 0];
276 }
277 unsafe { *self.values[term].assume_init_ref() }
280 }
281
282 fn merge_from(&mut self, other: &Self) {
283 for &word_idx in &other.touched_words {
284 let word_idx = word_idx as usize;
285 let mut pending = other.initialized[word_idx];
286 while pending != 0 {
287 let bit = pending.trailing_zeros() as usize;
288 let term = word_idx * 64 + bit;
289 let [left, right] = unsafe { *other.values[term].assume_init_ref() };
291 let entry = self.entry_mut(term);
292 entry[0] += left;
293 entry[1] += right;
294 pending &= pending - 1;
295 }
296 }
297 }
298
299 fn apply_moves_to(&self, degrees: &mut Self) {
305 for &word_idx in &self.touched_words {
306 let word_idx = word_idx as usize;
307 let mut pending = self.initialized[word_idx];
308 while pending != 0 {
309 let bit = pending.trailing_zeros() as usize;
310 let term = word_idx * 64 + bit;
311 let [right_to_left, left_to_right] =
313 unsafe { *self.values[term].assume_init_ref() };
314 debug_assert!(
315 degrees.initialized[word_idx] & (1u64 << bit) != 0,
316 "a moved term must already exist in the partition degrees"
317 );
318 let degree = degrees.entry_mut(term);
319 let new_left =
320 i64::from(degree[0]) + i64::from(right_to_left) - i64::from(left_to_right);
321 let new_right =
322 i64::from(degree[1]) + i64::from(left_to_right) - i64::from(right_to_left);
323 debug_assert!(new_left >= 0 && new_right >= 0);
324 debug_assert!(new_left <= i64::from(u32::MAX));
325 debug_assert!(new_right <= i64::from(u32::MAX));
326 degree[0] = new_left as u32;
327 degree[1] = new_right as u32;
328 pending &= pending - 1;
329 }
330 }
331 }
332
333 fn bisection_objective(&self, left_size: usize, right_size: usize, log_table: &[f32]) -> f64 {
340 let mut objective = 0.0f64;
341 let side_log = [
342 fast_log2_lookup(left_size, log_table) as f64,
343 fast_log2_lookup(right_size, log_table) as f64,
344 ];
345 for &word_idx in &self.touched_words {
346 let word_idx = word_idx as usize;
347 let mut pending = self.initialized[word_idx];
348 while pending != 0 {
349 let bit = pending.trailing_zeros() as usize;
350 let term = word_idx * 64 + bit;
351 let [left, right] = unsafe { *self.values[term].assume_init_ref() };
353 for (side, count) in [left, right].into_iter().enumerate() {
354 if count > 0 {
355 objective += count as f64
356 * (fast_log2_lookup(count as usize + 1, log_table) as f64
357 - side_log[side]);
358 }
359 }
360 pending &= pending - 1;
361 }
362 }
363 objective
364 }
365}
366
367pub(crate) struct ForwardIndex {
373 terms: Vec<u32>,
374 offsets: Vec<u64>,
379 pub num_terms: usize,
380 parallel_bisect_lanes: usize,
384 budget_limited: bool,
388}
389
390fn build_csr_offsets(counts: &[u32]) -> Vec<u64> {
393 let mut offsets = Vec::with_capacity(counts.len() + 1);
394 offsets.push(0u64);
395 for &c in counts {
396 offsets.push(offsets.last().unwrap() + c as u64);
397 }
398 offsets
399}
400
401impl ForwardIndex {
402 #[inline]
403 pub fn num_docs(&self) -> usize {
404 if self.offsets.is_empty() {
405 0
406 } else {
407 self.offsets.len() - 1
408 }
409 }
410
411 #[inline]
412 fn doc_terms(&self, doc: usize) -> &[u32] {
413 let start = self.offsets[doc] as usize;
414 let end = self.offsets[doc + 1] as usize;
415 &self.terms[start..end]
416 }
417
418 pub fn total_postings(&self) -> u64 {
420 self.offsets.last().copied().unwrap_or(0)
421 }
422
423 #[inline]
424 pub fn budget_limited(&self) -> bool {
425 self.budget_limited
426 }
427}
428
429pub(crate) fn build_vid_maps(
439 bmp: &crate::segment::reader::bmp::BmpIndex,
440) -> crate::Result<(Vec<u32>, Vec<u32>)> {
441 let ids = bmp.doc_map_ids_slice();
442 let num_virtual = bmp.num_virtual_docs as usize;
443 let expected_real = bmp.num_real_docs() as usize;
444 let mut virtual_to_real = vec![u32::MAX; num_virtual];
445 let mut real_to_virtual = Vec::with_capacity(expected_real);
446 debug_assert_eq!(ids.len(), virtual_to_real.len() * 4);
447 bmp.visit_real_slots_for_rewrite(|vid| {
448 virtual_to_real[vid] = real_to_virtual.len() as u32;
449 real_to_virtual.push(vid as u32);
450 })?;
451 Ok((virtual_to_real, real_to_virtual))
452}
453
454struct BlockJob {
460 src: u32,
461 block_id: u32,
462 real_start: u32,
464 real_len: u32,
466}
467
468fn build_block_jobs(
471 bmps: &[&crate::segment::reader::bmp::BmpIndex],
472 vid_maps: &[(Vec<u32>, Vec<u32>)],
473) -> Vec<BlockJob> {
474 let total_blocks: usize = bmps.iter().map(|b| b.num_blocks as usize).sum();
475 let mut jobs = Vec::with_capacity(total_blocks);
476 for (src, (bmp, (v2r, _))) in bmps.iter().zip(vid_maps).enumerate() {
477 let block_size = bmp.bmp_block_size as usize;
478 let mut real_cursor = 0u32;
479 for block_id in 0..bmp.num_blocks as usize {
480 let vid_start = block_id * block_size;
481 let vid_end = ((block_id + 1) * block_size).min(v2r.len());
482 let real_len = v2r[vid_start..vid_end]
483 .iter()
484 .filter(|&&r| r != u32::MAX)
485 .count() as u32;
486 jobs.push(BlockJob {
487 src: src as u32,
488 block_id: block_id as u32,
489 real_start: real_cursor,
490 real_len,
491 });
492 real_cursor += real_len;
493 }
494 }
495 jobs
496}
497
498#[cfg(test)]
512pub(crate) fn build_forward_index_from_bmps(
513 bmps: &[&crate::segment::reader::bmp::BmpIndex],
514 min_doc_freq: usize,
515 max_doc_freq: usize,
516 memory_budget_bytes: usize,
517) -> crate::Result<ForwardIndex> {
518 let vid_maps: Vec<(Vec<u32>, Vec<u32>)> = bmps
519 .iter()
520 .map(|bmp| build_vid_maps(bmp))
521 .collect::<crate::Result<_>>()?;
522 Ok(build_forward_index_from_bmps_with_maps(
523 bmps,
524 &vid_maps,
525 min_doc_freq,
526 max_doc_freq,
527 memory_budget_bytes,
528 ))
529}
530
531pub(crate) fn build_forward_index_from_bmps_with_maps(
535 bmps: &[&crate::segment::reader::bmp::BmpIndex],
536 vid_maps: &[(Vec<u32>, Vec<u32>)],
537 min_doc_freq: usize,
538 max_doc_freq: usize,
539 memory_budget_bytes: usize,
540) -> ForwardIndex {
541 debug_assert_eq!(bmps.len(), vid_maps.len());
542 let total_docs: usize = vid_maps.iter().map(|(_, r2v)| r2v.len()).sum();
543
544 if total_docs == 0 {
545 return ForwardIndex {
546 terms: Vec::new(),
547 offsets: Vec::new(),
548 num_terms: 0,
549 parallel_bisect_lanes: 1,
550 budget_limited: false,
551 };
552 }
553
554 let jobs = build_block_jobs(bmps, vid_maps);
559
560 let max_dims = bmps
564 .iter()
565 .map(|bmp| bmp.dims() as usize)
566 .max()
567 .unwrap_or(0);
568 let jobs_bytes = jobs
569 .len()
570 .saturating_mul(std::mem::size_of::<BlockJob>().saturating_add(40));
571 let frequency_bytes = max_dims.saturating_mul(std::mem::size_of::<u32>());
572 if frequency_bytes > memory_budget_bytes.saturating_sub(jobs_bytes) {
573 log::warn!(
574 "[reorder] memory budget {} cannot hold the {} dimension-frequency table; using identity order",
575 crate::format_bytes(memory_budget_bytes as u64),
576 crate::format_bytes(frequency_bytes as u64),
577 );
578 return ForwardIndex {
579 terms: Vec::new(),
580 offsets: Vec::new(),
581 num_terms: 0,
582 parallel_bisect_lanes: 1,
583 budget_limited: true,
584 };
585 }
586 let Some(dim_df) = count_frequencies_bounded(
587 &jobs,
588 max_dims,
589 memory_budget_bytes.saturating_sub(jobs_bytes),
590 |job, counts| {
591 let bmp = bmps[job.src as usize];
592 let (v2r, _) = &vid_maps[job.src as usize];
593 let block_size = bmp.bmp_block_size as usize;
594 for (dim_id, _, postings) in bmp.iter_block_terms(job.block_id) {
595 let mut frequency = 0u32;
596 for posting in postings {
597 let vid = job.block_id as usize * block_size + posting.local_slot as usize;
598 if v2r.get(vid).is_some_and(|&real| real != u32::MAX) && posting.impact > 0 {
599 frequency = frequency.saturating_add(1);
600 }
601 }
602 if frequency > 0
603 && let Some(total) = counts.get_mut(dim_id as usize)
604 {
605 *total = total.saturating_add(frequency);
606 }
607 }
608 },
609 ) else {
610 log::warn!(
611 "[reorder] memory budget {} cannot hold a bounded dimension-frequency table; using identity order",
612 crate::format_bytes(memory_budget_bytes as u64),
613 );
614 return ForwardIndex {
615 terms: Vec::new(),
616 offsets: Vec::new(),
617 num_terms: 0,
618 parallel_bisect_lanes: 1,
619 budget_limited: true,
620 };
621 };
622
623 let (mut eligible, mut budget_limited) = select_frequency_candidates(
627 &dim_df,
628 min_doc_freq,
629 max_doc_freq,
630 memory_budget_bytes
631 .saturating_sub(jobs_bytes)
632 .saturating_sub(frequency_bytes),
633 );
634 drop(dim_df);
635
636 let entity_scratch_bytes = total_docs.saturating_mul(32);
640 let remap_bytes = max_dims.saturating_mul(4);
641 let fixed_bytes = entity_scratch_bytes
642 .saturating_add(remap_bytes)
643 .saturating_add(jobs_bytes);
644 let fit = fit_candidates_to_budget(&mut eligible, fixed_bytes, memory_budget_bytes);
645 if fit.dropped > 0 {
646 budget_limited = true;
647 log::warn!(
648 "[reorder] memory budget {}: estimated {}, dropped {} highest-df dims, keeping {} ({} postings)",
649 crate::format_bytes(memory_budget_bytes as u64),
650 crate::format_bytes(fit.estimated_bytes as u64),
651 fit.dropped,
652 eligible.len(),
653 fit.retained_postings,
654 );
655 }
656
657 if eligible.is_empty() {
658 return ForwardIndex {
663 terms: Vec::new(),
664 offsets: Vec::new(),
665 num_terms: 0,
666 parallel_bisect_lanes: 1,
667 budget_limited,
668 };
669 }
670
671 let mut term_remap = vec![u32::MAX; max_dims];
672 for (compact_id, &(dim_id, _)) in eligible.iter().enumerate() {
673 term_remap[dim_id as usize] = compact_id as u32;
674 }
675 let num_active_terms = eligible.len();
676 let non_degree_bytes = entity_scratch_bytes.saturating_add(
681 fit.retained_postings
682 .saturating_mul(std::mem::size_of::<u32>()),
683 );
684 let parallel_bisect_lanes =
685 parallel_bisect_lanes(memory_budget_bytes, non_degree_bytes, num_active_terms);
686 drop(eligible);
687
688 let mut counts = vec![0u32; total_docs];
690 let fill_block_counts = |job: &BlockJob, out: &mut [u32]| {
691 let bmp = bmps[job.src as usize];
692 let (v2r, _) = &vid_maps[job.src as usize];
693 let block_size = bmp.bmp_block_size as usize;
694 for (dim_id, _, postings) in bmp.iter_block_terms(job.block_id) {
695 if term_remap.get(dim_id as usize).copied().unwrap_or(u32::MAX) == u32::MAX {
696 continue;
697 }
698 for p in postings {
699 let vid = job.block_id as usize * block_size + p.local_slot as usize;
700 let Some(&real) = v2r.get(vid) else {
701 continue;
702 };
703 if real != u32::MAX && p.impact > 0 {
704 out[(real - job.real_start) as usize] += 1;
705 }
706 }
707 }
708 };
709 {
710 let mut slices: Vec<(&BlockJob, &mut [u32])> = Vec::with_capacity(jobs.len());
711 let mut rest: &mut [u32] = &mut counts;
712 for job in &jobs {
713 let (head, tail) = rest.split_at_mut(job.real_len as usize);
714 slices.push((job, head));
715 rest = tail;
716 }
717 #[cfg(feature = "native")]
718 slices
719 .into_par_iter()
720 .for_each(|(job, out)| fill_block_counts(job, out));
721 #[cfg(not(feature = "native"))]
722 for (job, out) in slices {
723 fill_block_counts(job, out);
724 }
725 }
726
727 let offsets = build_csr_offsets(&counts);
729 let total = *offsets.last().unwrap() as usize;
730 drop(counts);
731
732 let mut terms = vec![0u32; total];
735 let fill_block_terms = |job: &BlockJob, global_real_start: usize, out: &mut [u32]| {
736 let bmp = bmps[job.src as usize];
737 let (v2r, _) = &vid_maps[job.src as usize];
738 let block_size = bmp.bmp_block_size as usize;
739 assert!(job.real_len as usize <= 256, "BMP block exceeds 256 docs");
741 let mut cursor = [0u32; 256];
742 let base = offsets[global_real_start] as usize;
743 for (dim_id, _, postings) in bmp.iter_block_terms(job.block_id) {
744 let compact = term_remap.get(dim_id as usize).copied().unwrap_or(u32::MAX);
745 if compact == u32::MAX {
746 continue;
747 }
748 for p in postings {
749 let vid = job.block_id as usize * block_size + p.local_slot as usize;
750 let Some(&real) = v2r.get(vid) else {
751 continue;
752 };
753 if real != u32::MAX && p.impact > 0 {
754 let local = (real - job.real_start) as usize;
755 let pos =
756 offsets[global_real_start + local] as usize - base + cursor[local] as usize;
757 out[pos] = compact;
758 cursor[local] += 1;
759 }
760 }
761 }
762 };
763 {
764 let mut slices: Vec<(&BlockJob, usize, &mut [u32])> = Vec::with_capacity(jobs.len());
765 let mut rest: &mut [u32] = &mut terms;
766 let mut global_real = 0usize;
767 for job in &jobs {
768 let len =
769 (offsets[global_real + job.real_len as usize] - offsets[global_real]) as usize;
770 let (head, tail) = rest.split_at_mut(len);
771 slices.push((job, global_real, head));
772 rest = tail;
773 global_real += job.real_len as usize;
774 }
775 #[cfg(feature = "native")]
776 slices
777 .into_par_iter()
778 .for_each(|(job, g, out)| fill_block_terms(job, g, out));
779 #[cfg(not(feature = "native"))]
780 for (job, g, out) in slices {
781 fill_block_terms(job, g, out);
782 }
783 }
784
785 ForwardIndex {
786 terms,
787 offsets,
788 num_terms: num_active_terms,
789 parallel_bisect_lanes,
790 budget_limited,
791 }
792}
793
794pub(crate) fn build_forward_index_from_blocks(
802 bmps: &[&crate::segment::reader::bmp::BmpIndex],
803 memory_budget_bytes: usize,
804) -> ForwardIndex {
805 let total_blocks: usize = bmps.iter().map(|b| b.num_blocks as usize).sum();
806 if total_blocks == 0 {
807 return ForwardIndex {
808 terms: Vec::new(),
809 offsets: Vec::new(),
810 num_terms: 0,
811 parallel_bisect_lanes: 1,
812 budget_limited: false,
813 };
814 }
815
816 let blocks: Vec<(u32, u32)> = bmps
818 .iter()
819 .enumerate()
820 .flat_map(|(src, bmp)| (0..bmp.num_blocks).map(move |b| (src as u32, b)))
821 .collect();
822
823 let max_dims = bmps
826 .iter()
827 .map(|bmp| bmp.dims() as usize)
828 .max()
829 .unwrap_or(0);
830 let blocks_bytes = blocks
831 .len()
832 .saturating_mul(std::mem::size_of::<(u32, u32)>().saturating_add(32));
833 let frequency_bytes = max_dims.saturating_mul(std::mem::size_of::<u32>());
834 if frequency_bytes > memory_budget_bytes.saturating_sub(blocks_bytes) {
835 log::warn!(
836 "[reorder] block-level frequency table exceeds memory budget; using identity order"
837 );
838 return ForwardIndex {
839 terms: Vec::new(),
840 offsets: Vec::new(),
841 num_terms: 0,
842 parallel_bisect_lanes: 1,
843 budget_limited: true,
844 };
845 }
846 let Some(dim_bf) = count_frequencies_bounded(
847 &blocks,
848 max_dims,
849 memory_budget_bytes.saturating_sub(blocks_bytes),
850 |&(src, block_id), counts| {
851 for (dim_id, _, _) in bmps[src as usize].iter_block_terms(block_id) {
852 if let Some(count) = counts.get_mut(dim_id as usize) {
853 *count = count.saturating_add(1);
854 }
855 }
856 },
857 ) else {
858 log::warn!(
859 "[reorder] block-level frequency table cannot fit its bounded allocation; using identity order"
860 );
861 return ForwardIndex {
862 terms: Vec::new(),
863 offsets: Vec::new(),
864 num_terms: 0,
865 parallel_bisect_lanes: 1,
866 budget_limited: true,
867 };
868 };
869
870 let max_bf = (total_blocks as f64 * 0.9) as usize;
871 let (mut eligible, mut budget_limited) = select_frequency_candidates(
872 &dim_bf,
873 2,
874 max_bf.max(2),
875 memory_budget_bytes
876 .saturating_sub(blocks_bytes)
877 .saturating_sub(frequency_bytes),
878 );
879 drop(dim_bf);
880
881 let entity_scratch_bytes = total_blocks.saturating_mul(32);
882 let remap_bytes = max_dims.saturating_mul(4);
883 let fixed_bytes = entity_scratch_bytes
884 .saturating_add(remap_bytes)
885 .saturating_add(blocks_bytes);
886 let fit = fit_candidates_to_budget(&mut eligible, fixed_bytes, memory_budget_bytes);
887 if fit.dropped > 0 {
888 budget_limited = true;
889 log::warn!(
890 "[reorder] block-level fwd index over budget — dropped {} highest-bf dims",
891 fit.dropped,
892 );
893 }
894
895 if eligible.is_empty() {
896 return ForwardIndex {
897 terms: Vec::new(),
898 offsets: Vec::new(),
899 num_terms: 0,
900 parallel_bisect_lanes: 1,
901 budget_limited,
902 };
903 }
904
905 let mut term_remap = vec![u32::MAX; max_dims];
906 for (compact, &(dim_id, _)) in eligible.iter().enumerate() {
907 term_remap[dim_id as usize] = compact as u32;
908 }
909 let num_terms = eligible.len();
910 let non_degree_bytes = entity_scratch_bytes.saturating_add(
911 fit.retained_postings
912 .saturating_mul(std::mem::size_of::<u32>()),
913 );
914 let parallel_bisect_lanes =
915 parallel_bisect_lanes(memory_budget_bytes, non_degree_bytes, num_terms);
916 drop(eligible);
917
918 let count_remapped = |&(src, block_id): &(u32, u32)| -> u32 {
921 bmps[src as usize]
922 .iter_block_terms(block_id)
923 .filter(|(dim_id, _, _)| {
924 term_remap
925 .get(*dim_id as usize)
926 .copied()
927 .unwrap_or(u32::MAX)
928 != u32::MAX
929 })
930 .count() as u32
931 };
932 #[cfg(feature = "native")]
933 let counts: Vec<u32> = blocks.par_iter().map(count_remapped).collect();
934 #[cfg(not(feature = "native"))]
935 let counts: Vec<u32> = blocks.iter().map(count_remapped).collect();
936
937 let offsets = build_csr_offsets(&counts);
938 let total = *offsets.last().unwrap() as usize;
939 drop(counts);
940
941 let mut terms = vec![0u32; total];
942 let fill_block = |&(src, block_id): &(u32, u32), out: &mut [u32]| {
943 let mut n = 0usize;
944 for (dim_id, _, _) in bmps[src as usize].iter_block_terms(block_id) {
945 let compact = term_remap.get(dim_id as usize).copied().unwrap_or(u32::MAX);
946 if compact != u32::MAX {
947 out[n] = compact;
948 n += 1;
949 }
950 }
951 };
952 {
953 let mut slices: Vec<(&(u32, u32), &mut [u32])> = Vec::with_capacity(blocks.len());
954 let mut rest: &mut [u32] = &mut terms;
955 for (gb, b) in blocks.iter().enumerate() {
956 let len = (offsets[gb + 1] - offsets[gb]) as usize;
957 let (head, tail) = rest.split_at_mut(len);
958 slices.push((b, head));
959 rest = tail;
960 }
961 #[cfg(feature = "native")]
962 slices
963 .into_par_iter()
964 .for_each(|(b, out)| fill_block(b, out));
965 #[cfg(not(feature = "native"))]
966 for (b, out) in slices {
967 fill_block(b, out);
968 }
969 }
970
971 ForwardIndex {
972 terms,
973 offsets,
974 num_terms,
975 parallel_bisect_lanes,
976 budget_limited,
977 }
978}
979
980#[derive(Clone, Copy, Debug, Default)]
988pub struct BpBudget {
989 pub min_partition_docs: Option<usize>,
994 pub time_budget: Option<std::time::Duration>,
998}
999
1000impl BpBudget {
1001 pub fn full() -> Self {
1003 Self::default()
1004 }
1005}
1006
1007fn build_term_degrees(
1014 docs: &[u32],
1015 mid: usize,
1016 fwd: &ForwardIndex,
1017 workspaces: &mut [TermDegrees],
1018 cancellation: Option<&std::sync::atomic::AtomicBool>,
1019) {
1020 debug_assert!(!workspaces.is_empty());
1021 let build_range = |degrees: &mut TermDegrees, start: usize, chunk: &[u32]| {
1022 degrees.reset();
1023 for (offset, &doc) in chunk.iter().enumerate() {
1024 if offset.is_multiple_of(1024)
1025 && cancellation
1026 .is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Relaxed))
1027 {
1028 break;
1029 }
1030 let side = usize::from(start + offset >= mid);
1031 for &term in fwd.doc_terms(doc as usize) {
1032 degrees.entry_mut(term as usize)[side] += 1;
1033 }
1034 }
1035 };
1036
1037 #[cfg(feature = "native")]
1038 {
1039 let workers = workspaces
1040 .len()
1041 .min(docs.len().div_ceil(PARALLEL_BP_MIN_ENTITIES).max(1));
1042 if workers > 1 {
1043 let chunk_len = docs.len().div_ceil(workers);
1044 workspaces[..workers]
1045 .par_iter_mut()
1046 .enumerate()
1047 .for_each(|(worker, degrees)| {
1048 let start = worker * chunk_len;
1049 let end = (start + chunk_len).min(docs.len());
1050 build_range(degrees, start, &docs[start..end]);
1051 });
1052 let (degrees, partials) = workspaces[..workers]
1053 .split_first_mut()
1054 .expect("at least one BP degree workspace");
1055 for partial in partials {
1056 degrees.merge_from(partial);
1057 }
1058 return;
1059 }
1060 build_range(&mut workspaces[0], 0, docs);
1061 }
1062
1063 #[cfg(not(feature = "native"))]
1064 build_range(&mut workspaces[0], 0, docs);
1065}
1066
1067#[inline]
1073fn gain_order_key(gain: f32) -> u32 {
1074 let bits = gain.to_bits();
1075 if bits & 0x8000_0000 != 0 {
1076 !bits
1077 } else {
1078 bits ^ 0x8000_0000
1079 }
1080}
1081
1082fn select_gain_threshold(
1086 gains: &[f32],
1087 left_count: usize,
1088 allow_inner_parallelism: bool,
1089) -> (u32, usize) {
1090 debug_assert!(left_count > 0 && left_count <= gains.len());
1091 let mut rank_within_prefix = left_count - 1;
1092 let mut strictly_lower = 0usize;
1093 let mut prefix = 0u32;
1094 let mut prefix_mask = 0u32;
1095
1096 for shift in [24u32, 16, 8, 0] {
1097 let histogram = {
1098 #[cfg(feature = "native")]
1099 {
1100 if allow_inner_parallelism && gains.len() >= PARALLEL_BP_MIN_ENTITIES {
1101 gains
1102 .par_iter()
1103 .fold(
1104 || Box::new([0usize; 256]),
1105 |mut counts, &gain| {
1106 let key = gain_order_key(gain);
1107 if key & prefix_mask == prefix {
1108 counts[((key >> shift) & 0xff) as usize] += 1;
1109 }
1110 counts
1111 },
1112 )
1113 .reduce(
1114 || Box::new([0usize; 256]),
1115 |mut left, right| {
1116 for (dst, &count) in left.iter_mut().zip(right.iter()) {
1117 *dst += count;
1118 }
1119 left
1120 },
1121 )
1122 } else {
1123 let mut counts = [0usize; 256];
1124 for &gain in gains {
1125 let key = gain_order_key(gain);
1126 if key & prefix_mask == prefix {
1127 counts[((key >> shift) & 0xff) as usize] += 1;
1128 }
1129 }
1130 Box::new(counts)
1131 }
1132 }
1133 #[cfg(not(feature = "native"))]
1134 {
1135 let mut counts = [0usize; 256];
1136 for &gain in gains {
1137 let key = gain_order_key(gain);
1138 if key & prefix_mask == prefix {
1139 counts[((key >> shift) & 0xff) as usize] += 1;
1140 }
1141 }
1142 counts
1143 }
1144 };
1145
1146 let mut before_bucket = 0usize;
1147 let mut selected_bucket = None;
1148 for (bucket, count) in histogram.iter().copied().enumerate() {
1149 if rank_within_prefix < before_bucket + count {
1150 selected_bucket = Some(bucket as u32);
1151 rank_within_prefix -= before_bucket;
1152 strictly_lower += before_bucket;
1153 break;
1154 }
1155 before_bucket += count;
1156 }
1157 let selected_bucket = selected_bucket.expect("BP radix selection lost the requested rank");
1158 prefix |= selected_bucket << shift;
1159 prefix_mask |= 0xffu32 << shift;
1160 }
1161
1162 (prefix, strictly_lower)
1163}
1164
1165enum PartitionDegreeUpdate {
1166 Moves,
1169 Ranked,
1173 Threshold {
1176 threshold_key: u32,
1177 ties_left: usize,
1178 },
1179}
1180
1181struct PartitionOutcome {
1182 swap_count: usize,
1183 degree_update: PartitionDegreeUpdate,
1184}
1185
1186#[derive(Clone, Copy)]
1187struct PartitionChunk {
1188 start: usize,
1189 end: usize,
1190 strictly_lower: usize,
1191 equal: usize,
1192 ties_left: usize,
1193}
1194
1195#[inline]
1196fn select_left(key: u32, threshold_key: u32, equal_seen: &mut usize, ties_left: usize) -> bool {
1197 if key < threshold_key {
1198 true
1199 } else if key == threshold_key {
1200 let selected = *equal_seen < ties_left;
1201 *equal_seen += 1;
1202 selected
1203 } else {
1204 false
1205 }
1206}
1207
1208#[allow(clippy::too_many_arguments)]
1217fn partition_by_gain(
1218 docs: &[u32],
1219 gains: &[f32],
1220 mid: usize,
1221 fwd: &ForwardIndex,
1222 movement_workspaces: &mut [TermDegrees],
1223 output: &mut [u32],
1224 ranked_scratch: &mut [usize],
1225 allow_inner_parallelism: bool,
1226) -> PartitionOutcome {
1227 #[cfg(not(feature = "native"))]
1228 let _ = (fwd, &movement_workspaces);
1229
1230 #[cfg(feature = "native")]
1231 if allow_inner_parallelism
1232 && !movement_workspaces.is_empty()
1233 && docs.len() >= PARALLEL_BP_MIN_ENTITIES
1234 {
1235 let (threshold_key, strictly_lower) =
1236 select_gain_threshold(gains, mid, allow_inner_parallelism);
1237 let ties_left = mid - strictly_lower;
1238
1239 let chunk_count = movement_workspaces
1242 .len()
1243 .min(docs.len().div_ceil(PARALLEL_BP_MIN_ENTITIES).max(1));
1244 let chunk_len = docs.len().div_ceil(chunk_count);
1245 let mut chunks: Vec<PartitionChunk> = gains
1246 .par_chunks(chunk_len)
1247 .enumerate()
1248 .map(|(chunk_id, chunk)| {
1249 let mut lower = 0usize;
1250 let mut equal = 0usize;
1251 for &gain in chunk {
1252 match gain_order_key(gain).cmp(&threshold_key) {
1253 std::cmp::Ordering::Less => lower += 1,
1254 std::cmp::Ordering::Equal => equal += 1,
1255 std::cmp::Ordering::Greater => {}
1256 }
1257 }
1258 let start = chunk_id * chunk_len;
1259 PartitionChunk {
1260 start,
1261 end: start + chunk.len(),
1262 strictly_lower: lower,
1263 equal,
1264 ties_left: 0,
1265 }
1266 })
1267 .collect();
1268
1269 let mut remaining_ties = ties_left;
1270 for chunk in &mut chunks {
1271 chunk.ties_left = remaining_ties.min(chunk.equal);
1272 remaining_ties -= chunk.ties_left;
1273 }
1274 debug_assert_eq!(remaining_ties, 0);
1275
1276 let (mut left_rest, mut right_rest) = output.split_at_mut(mid);
1277 let mut jobs = Vec::with_capacity(chunks.len());
1278 for chunk in chunks {
1279 let left_len = chunk.strictly_lower + chunk.ties_left;
1280 let right_len = chunk.end - chunk.start - left_len;
1281 let (left_out, next_left) = left_rest.split_at_mut(left_len);
1282 let (right_out, next_right) = right_rest.split_at_mut(right_len);
1283 jobs.push((
1284 chunk.start,
1285 &docs[chunk.start..chunk.end],
1286 &gains[chunk.start..chunk.end],
1287 chunk.ties_left,
1288 left_out,
1289 right_out,
1290 ));
1291 left_rest = next_left;
1292 right_rest = next_right;
1293 }
1294 debug_assert!(left_rest.is_empty() && right_rest.is_empty());
1295
1296 let swap_count = movement_workspaces[..chunk_count]
1297 .par_iter_mut()
1298 .zip(jobs.into_par_iter())
1299 .map(
1300 |(moves, (start, docs, gains, ties_for_chunk, left_out, right_out))| {
1301 moves.reset();
1302 let mut equal_seen = 0usize;
1303 let mut left_cursor = 0usize;
1304 let mut right_cursor = 0usize;
1305 let mut swaps = 0usize;
1306
1307 for (offset, (&doc, &gain)) in docs.iter().zip(gains).enumerate() {
1308 let key = gain_order_key(gain);
1309 let now_left =
1310 select_left(key, threshold_key, &mut equal_seen, ties_for_chunk);
1311 if now_left {
1312 left_out[left_cursor] = doc;
1313 left_cursor += 1;
1314 } else {
1315 right_out[right_cursor] = doc;
1316 right_cursor += 1;
1317 }
1318
1319 let was_left = start + offset < mid;
1320 if was_left != now_left {
1321 swaps += 1;
1322 let direction = usize::from(was_left);
1324 for &term in fwd.doc_terms(doc as usize) {
1325 moves.entry_mut(term as usize)[direction] += 1;
1326 }
1327 }
1328 }
1329 debug_assert_eq!(left_cursor, left_out.len());
1330 debug_assert_eq!(right_cursor, right_out.len());
1331 swaps
1332 },
1333 )
1334 .sum();
1335
1336 let (moves, partials) = movement_workspaces[..chunk_count]
1337 .split_first_mut()
1338 .expect("parallel BP partition must use at least one movement workspace");
1339 for partial in partials {
1340 moves.merge_from(partial);
1341 }
1342
1343 return PartitionOutcome {
1344 swap_count,
1345 degree_update: PartitionDegreeUpdate::Moves,
1346 };
1347 }
1348
1349 if docs.len() < PARALLEL_BP_MIN_ENTITIES {
1350 debug_assert_eq!(ranked_scratch.len(), docs.len());
1351 for (index, rank) in ranked_scratch.iter_mut().enumerate() {
1352 *rank = index;
1353 }
1354 ranked_scratch.select_nth_unstable_by(mid, |&left, &right| {
1355 gains[left]
1356 .total_cmp(&gains[right])
1357 .then_with(|| left.cmp(&right))
1358 });
1359
1360 let mut swaps = 0usize;
1361 for (rank, &old_index) in ranked_scratch.iter().enumerate() {
1362 output[rank] = docs[old_index];
1363 swaps += usize::from((old_index < mid) != (rank < mid));
1364 }
1365 return PartitionOutcome {
1366 swap_count: swaps,
1367 degree_update: PartitionDegreeUpdate::Ranked,
1368 };
1369 }
1370
1371 let (threshold_key, strictly_lower) =
1372 select_gain_threshold(gains, mid, allow_inner_parallelism);
1373 let ties_left = mid - strictly_lower;
1374 let mut equal_seen = 0usize;
1375 let mut left_cursor = 0usize;
1376 let mut right_cursor = mid;
1377 let mut swaps = 0usize;
1378 for (idx, (&doc, &gain)) in docs.iter().zip(gains).enumerate() {
1379 let key = gain_order_key(gain);
1380 let now_left = select_left(key, threshold_key, &mut equal_seen, ties_left);
1381 if now_left {
1382 output[left_cursor] = doc;
1383 left_cursor += 1;
1384 } else {
1385 output[right_cursor] = doc;
1386 right_cursor += 1;
1387 }
1388 swaps += usize::from((idx < mid) != now_left);
1389 }
1390 debug_assert_eq!(left_cursor, mid);
1391 debug_assert_eq!(right_cursor, docs.len());
1392
1393 PartitionOutcome {
1394 swap_count: swaps,
1395 degree_update: PartitionDegreeUpdate::Threshold {
1396 threshold_key,
1397 ties_left,
1398 },
1399 }
1400}
1401
1402fn update_degrees_for_threshold_partition(
1404 docs: &[u32],
1405 gains: &[f32],
1406 mid: usize,
1407 threshold_key: u32,
1408 ties_left: usize,
1409 fwd: &ForwardIndex,
1410 degrees: &mut TermDegrees,
1411) {
1412 let mut equal_seen = 0usize;
1413 for (idx, (&doc, &gain)) in docs.iter().zip(gains).enumerate() {
1414 let key = gain_order_key(gain);
1415 let now_left = select_left(key, threshold_key, &mut equal_seen, ties_left);
1416 let was_left = idx < mid;
1417 if was_left == now_left {
1418 continue;
1419 }
1420 let left_delta = if was_left { -1i64 } else { 1i64 };
1421 for &term in fwd.doc_terms(doc as usize) {
1422 let degree = degrees.entry_mut(term as usize);
1423 let new_left = degree[0] as i64 + left_delta;
1424 let new_right = degree[1] as i64 - left_delta;
1425 debug_assert!(new_left >= 0 && new_right >= 0);
1426 degree[0] = new_left as u32;
1427 degree[1] = new_right as u32;
1428 }
1429 }
1430}
1431
1432fn update_degrees_for_ranked_partition(
1435 docs: &[u32],
1436 ranked: &[usize],
1437 mid: usize,
1438 fwd: &ForwardIndex,
1439 degrees: &mut TermDegrees,
1440) {
1441 for (rank, &old_index) in ranked.iter().enumerate() {
1442 let was_left = old_index < mid;
1443 let now_left = rank < mid;
1444 if was_left == now_left {
1445 continue;
1446 }
1447 let left_delta = if was_left { -1i64 } else { 1i64 };
1448 for &term in fwd.doc_terms(docs[old_index] as usize) {
1449 let degree = degrees.entry_mut(term as usize);
1450 let new_left = degree[0] as i64 + left_delta;
1451 let new_right = degree[1] as i64 - left_delta;
1452 debug_assert!(new_left >= 0 && new_right >= 0);
1453 degree[0] = new_left as u32;
1454 degree[1] = new_right as u32;
1455 }
1456 }
1457}
1458
1459#[derive(Clone, Copy)]
1460pub(crate) struct BpProgressLabel<'a> {
1461 pub index: &'a str,
1462 pub field: &'a str,
1463 pub entity_kind: &'static str,
1464}
1465
1466#[cfg(test)]
1467impl BpProgressLabel<'static> {
1468 fn anonymous() -> Self {
1469 Self {
1470 index: "unknown",
1471 field: "unknown",
1472 entity_kind: "entities",
1473 }
1474 }
1475}
1476
1477#[cfg(feature = "native")]
1478struct BpProgress<'a> {
1479 label: BpProgressLabel<'a>,
1480 start: std::time::Instant,
1481 total_entities: usize,
1482 total_postings: u64,
1483 expected_depth: usize,
1484 next_log_ms: std::sync::atomic::AtomicU64,
1485 active_partitions: std::sync::atomic::AtomicU64,
1486 partitions_started: std::sync::atomic::AtomicU64,
1487 partitions_completed: std::sync::atomic::AtomicU64,
1488 iterations: std::sync::atomic::AtomicU64,
1489 entity_passes: std::sync::atomic::AtomicU64,
1490 swaps: std::sync::atomic::AtomicU64,
1491 deepest_level: std::sync::atomic::AtomicU64,
1492 objective_stops: std::sync::atomic::AtomicU64,
1493 last_objective_delta_bits: std::sync::atomic::AtomicU64,
1494 last_relative_delta_bits: std::sync::atomic::AtomicU64,
1495 active_metric_released: std::sync::atomic::AtomicBool,
1496}
1497
1498#[cfg(feature = "native")]
1499impl<'a> BpProgress<'a> {
1500 fn new(
1501 label: BpProgressLabel<'a>,
1502 total_entities: usize,
1503 total_postings: u64,
1504 expected_depth: usize,
1505 degree_lanes: usize,
1506 ) -> Self {
1507 log::info!(
1508 "[reorder][bp] started: index={} field={} entity_kind={} scheduler=level_synchronized degree_lanes={} entities={} postings={} expected_depth={} objective_stall_threshold={:.1e}x{} min_objective_iterations={}",
1509 label.index,
1510 label.field,
1511 label.entity_kind,
1512 degree_lanes,
1513 total_entities,
1514 total_postings,
1515 expected_depth,
1516 MIN_RELATIVE_OBJECTIVE_IMPROVEMENT,
1517 OBJECTIVE_STALL_ITERATIONS,
1518 MIN_OBJECTIVE_ITERATIONS,
1519 );
1520 crate::observe::reorder_bp_started(label.index, label.field, label.entity_kind);
1521 Self {
1522 label,
1523 start: std::time::Instant::now(),
1524 total_entities,
1525 total_postings,
1526 expected_depth,
1527 next_log_ms: std::sync::atomic::AtomicU64::new(30_000),
1528 active_partitions: std::sync::atomic::AtomicU64::new(0),
1529 partitions_started: std::sync::atomic::AtomicU64::new(0),
1530 partitions_completed: std::sync::atomic::AtomicU64::new(0),
1531 iterations: std::sync::atomic::AtomicU64::new(0),
1532 entity_passes: std::sync::atomic::AtomicU64::new(0),
1533 swaps: std::sync::atomic::AtomicU64::new(0),
1534 deepest_level: std::sync::atomic::AtomicU64::new(0),
1535 objective_stops: std::sync::atomic::AtomicU64::new(0),
1536 last_objective_delta_bits: std::sync::atomic::AtomicU64::new(0f64.to_bits()),
1537 last_relative_delta_bits: std::sync::atomic::AtomicU64::new(0f64.to_bits()),
1538 active_metric_released: std::sync::atomic::AtomicBool::new(false),
1539 }
1540 }
1541
1542 fn partition_started(&self, level: usize) {
1543 self.active_partitions
1544 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1545 self.partitions_started
1546 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1547 self.deepest_level
1548 .fetch_max(level as u64, std::sync::atomic::Ordering::Relaxed);
1549 }
1550
1551 fn partition_finished(&self) {
1552 self.partitions_completed
1553 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1554 self.active_partitions
1555 .fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
1556 }
1557
1558 fn iteration(&self, entities: usize, swaps: usize, objective_delta: f64, relative_delta: f64) {
1559 self.iterations
1560 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1561 self.entity_passes
1562 .fetch_add(entities as u64, std::sync::atomic::Ordering::Relaxed);
1563 self.swaps
1564 .fetch_add(swaps as u64, std::sync::atomic::Ordering::Relaxed);
1565 self.last_objective_delta_bits.store(
1566 objective_delta.to_bits(),
1567 std::sync::atomic::Ordering::Relaxed,
1568 );
1569 self.last_relative_delta_bits.store(
1570 relative_delta.to_bits(),
1571 std::sync::atomic::Ordering::Relaxed,
1572 );
1573 self.maybe_log();
1574 }
1575
1576 fn objective_stop(&self) {
1577 self.objective_stops
1578 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1579 }
1580
1581 fn maybe_log(&self) {
1582 let elapsed_ms = self.start.elapsed().as_millis().min(u64::MAX as u128) as u64;
1583 let next = self.next_log_ms.load(std::sync::atomic::Ordering::Relaxed);
1584 if elapsed_ms < next
1585 || self
1586 .next_log_ms
1587 .compare_exchange(
1588 next,
1589 elapsed_ms.saturating_add(30_000),
1590 std::sync::atomic::Ordering::Relaxed,
1591 std::sync::atomic::Ordering::Relaxed,
1592 )
1593 .is_err()
1594 {
1595 return;
1596 }
1597
1598 let active = self
1599 .active_partitions
1600 .load(std::sync::atomic::Ordering::Relaxed);
1601 let started = self
1602 .partitions_started
1603 .load(std::sync::atomic::Ordering::Relaxed);
1604 let completed = self
1605 .partitions_completed
1606 .load(std::sync::atomic::Ordering::Relaxed);
1607 let iterations = self.iterations.load(std::sync::atomic::Ordering::Relaxed);
1608 let entity_passes = self
1609 .entity_passes
1610 .load(std::sync::atomic::Ordering::Relaxed);
1611 let swaps = self.swaps.load(std::sync::atomic::Ordering::Relaxed);
1612 let deepest = self
1613 .deepest_level
1614 .load(std::sync::atomic::Ordering::Relaxed);
1615 let objective_delta = f64::from_bits(
1616 self.last_objective_delta_bits
1617 .load(std::sync::atomic::Ordering::Relaxed),
1618 );
1619 let relative_delta = f64::from_bits(
1620 self.last_relative_delta_bits
1621 .load(std::sync::atomic::Ordering::Relaxed),
1622 );
1623 log::info!(
1624 "[reorder][bp] progress: index={} field={} entity_kind={} elapsed={:.1}s depth={}/{} partitions={}/{} active={} iterations={} entity_passes={} swaps={} last_objective_delta={:.3} relative={:.3e}",
1625 self.label.index,
1626 self.label.field,
1627 self.label.entity_kind,
1628 self.start.elapsed().as_secs_f64(),
1629 deepest,
1630 self.expected_depth,
1631 completed,
1632 started,
1633 active,
1634 iterations,
1635 entity_passes,
1636 swaps,
1637 objective_delta,
1638 relative_delta,
1639 );
1640 }
1641
1642 fn finish(
1643 &self,
1644 converged: bool,
1645 memory_limited: bool,
1646 deadline_exhausted: bool,
1647 cancelled: bool,
1648 ) {
1649 let elapsed = self.start.elapsed().as_secs_f64();
1650 let partitions = self
1651 .partitions_completed
1652 .load(std::sync::atomic::Ordering::Relaxed);
1653 let iterations = self.iterations.load(std::sync::atomic::Ordering::Relaxed);
1654 let entity_passes = self
1655 .entity_passes
1656 .load(std::sync::atomic::Ordering::Relaxed);
1657 let swaps = self.swaps.load(std::sync::atomic::Ordering::Relaxed);
1658 let deepest = self
1659 .deepest_level
1660 .load(std::sync::atomic::Ordering::Relaxed);
1661 let objective_stops = self
1662 .objective_stops
1663 .load(std::sync::atomic::Ordering::Relaxed);
1664 let stop_reason = if cancelled {
1665 "shutdown"
1666 } else if memory_limited {
1667 "memory_budget"
1668 } else if deadline_exhausted {
1669 "time_budget"
1670 } else if objective_stops > 0 {
1671 "objective"
1672 } else {
1673 "complete"
1674 };
1675 log::info!(
1676 "[reorder][bp] completed: index={} field={} entity_kind={} entities={} postings={} elapsed={:.1}s depth={}/{} partitions={} iterations={} entity_passes={} swaps={} objective_stops={} converged={} stop_reason={}",
1677 self.label.index,
1678 self.label.field,
1679 self.label.entity_kind,
1680 self.total_entities,
1681 self.total_postings,
1682 elapsed,
1683 deepest,
1684 self.expected_depth,
1685 partitions,
1686 iterations,
1687 entity_passes,
1688 swaps,
1689 objective_stops,
1690 converged,
1691 stop_reason,
1692 );
1693 crate::observe::reorder_bp_pass(
1694 self.label.index,
1695 self.label.field,
1696 self.label.entity_kind,
1697 stop_reason,
1698 elapsed,
1699 self.total_entities,
1700 self.total_postings,
1701 partitions,
1702 iterations,
1703 entity_passes,
1704 swaps,
1705 converged,
1706 );
1707 self.release_active_metric();
1708 }
1709
1710 fn release_active_metric(&self) {
1711 if !self
1712 .active_metric_released
1713 .swap(true, std::sync::atomic::Ordering::AcqRel)
1714 {
1715 crate::observe::reorder_bp_finished(
1716 self.label.index,
1717 self.label.field,
1718 self.label.entity_kind,
1719 );
1720 }
1721 }
1722}
1723
1724#[cfg(feature = "native")]
1725impl Drop for BpProgress<'_> {
1726 fn drop(&mut self) {
1727 self.release_active_metric();
1728 }
1729}
1730
1731#[cfg(not(feature = "native"))]
1732struct BpProgress<'a>(std::marker::PhantomData<&'a ()>);
1733
1734#[cfg(not(feature = "native"))]
1735impl BpProgress<'_> {
1736 fn new(_: BpProgressLabel<'_>, _: usize, _: u64, _: usize, _: usize) -> Self {
1737 Self(std::marker::PhantomData)
1738 }
1739 fn partition_started(&self, _: usize) {}
1740 fn partition_finished(&self) {}
1741 fn iteration(&self, _: usize, _: usize, _: f64, _: f64) {}
1742 fn objective_stop(&self) {}
1743 fn finish(&self, _: bool, _: bool, _: bool, _: bool) {}
1744}
1745
1746#[cfg(test)]
1757pub(crate) fn graph_bisection(
1758 fwd: &ForwardIndex,
1759 min_partition_size: usize,
1760 max_iters: usize,
1761 budget: BpBudget,
1762) -> (Vec<u32>, bool) {
1763 graph_bisection_with_progress(
1764 fwd,
1765 min_partition_size,
1766 max_iters,
1767 budget,
1768 None,
1769 BpProgressLabel::anonymous(),
1770 )
1771}
1772
1773pub(crate) fn graph_bisection_with_progress(
1774 fwd: &ForwardIndex,
1775 min_partition_size: usize,
1776 max_iters: usize,
1777 budget: BpBudget,
1778 cancellation: Option<&std::sync::atomic::AtomicBool>,
1779 progress_label: BpProgressLabel<'_>,
1780) -> (Vec<u32>, bool) {
1781 let n = fwd.num_docs();
1782 if n == 0 {
1783 return (Vec::new(), !fwd.budget_limited);
1784 }
1785
1786 let effective_min_partition = budget
1787 .min_partition_docs
1788 .unwrap_or(0)
1789 .max(min_partition_size)
1790 .max(1);
1795
1796 let mut docs: Vec<u32> = (0..n as u32).collect();
1797 let depth = if effective_min_partition > 0 {
1798 ((n as f64) / (effective_min_partition as f64))
1799 .log2()
1800 .ceil() as usize
1801 } else {
1802 0
1803 };
1804 #[cfg(feature = "native")]
1805 let degree_lanes = fwd.parallel_bisect_lanes.max(1);
1806 #[cfg(not(feature = "native"))]
1807 let degree_lanes = 1usize;
1808 let log_table = build_log_table(4096);
1809 let progress = BpProgress::new(progress_label, n, fwd.total_postings(), depth, degree_lanes);
1810
1811 log::debug!(
1812 "BP graph_bisection: n={}, min_partition={}, max_iters={}, depth=~{}, time_budget={:?}",
1813 n,
1814 effective_min_partition,
1815 max_iters,
1816 depth,
1817 budget.time_budget,
1818 );
1819
1820 #[cfg(feature = "native")]
1821 let deadline = budget.time_budget.map(|duration| {
1822 let now = std::time::Instant::now();
1823 now.checked_add(duration).unwrap_or(now)
1824 });
1825 #[cfg(not(feature = "native"))]
1826 let deadline: Option<()> = None;
1827
1828 let exhausted = std::sync::atomic::AtomicBool::new(false);
1829 let context = BisectContext {
1830 fwd,
1831 min_partition_size: effective_min_partition,
1832 max_iters,
1833 log_table: &log_table,
1834 #[cfg(feature = "native")]
1835 deadline,
1836 #[cfg(not(feature = "native"))]
1837 deadline,
1838 exhausted: &exhausted,
1839 cancellation,
1840 progress: &progress,
1841 };
1842 let immediately_exhausted = {
1843 #[cfg(feature = "native")]
1844 {
1845 deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline)
1846 }
1847 #[cfg(not(feature = "native"))]
1848 {
1849 false
1850 }
1851 };
1852 let initially_cancelled =
1853 cancellation.is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Acquire));
1854 if immediately_exhausted || initially_cancelled {
1855 exhausted.store(true, std::sync::atomic::Ordering::Relaxed);
1856 } else if n > effective_min_partition {
1857 let mut gains = vec![0.0f32; n];
1861 let mut partitioned = vec![0u32; n];
1862 let mut ranked = vec![0usize; n];
1863 let mut degree_workspaces: Vec<TermDegrees> = (0..degree_lanes)
1864 .map(|_| TermDegrees::new(fwd.num_terms))
1865 .collect();
1866 bisect_level_synchronized(
1867 &mut docs,
1868 &mut gains,
1869 &mut partitioned,
1870 &mut ranked,
1871 &mut degree_workspaces,
1872 &context,
1873 );
1874 }
1875
1876 let stopped_early = exhausted.load(std::sync::atomic::Ordering::Relaxed);
1877 let cancelled =
1878 cancellation.is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Acquire));
1879 let deadline_exhausted = stopped_early && !cancelled;
1880 let converged = !fwd.budget_limited && !stopped_early && !cancelled;
1881 progress.finish(converged, fwd.budget_limited, deadline_exhausted, cancelled);
1882 if !converged {
1883 log::info!(
1884 "BP graph_bisection: pass incomplete at n={} (time={:?}, memory_limited={}, cancelled={}) — emitting partial (still valid) permutation",
1885 n,
1886 budget.time_budget,
1887 fwd.budget_limited,
1888 cancelled,
1889 );
1890 }
1891 (docs, converged)
1892}
1893
1894struct BisectContext<'a> {
1900 fwd: &'a ForwardIndex,
1901 min_partition_size: usize,
1902 max_iters: usize,
1903 log_table: &'a [f32],
1904 #[cfg(feature = "native")]
1905 deadline: Option<std::time::Instant>,
1906 #[cfg(not(feature = "native"))]
1907 deadline: Option<()>,
1908 exhausted: &'a std::sync::atomic::AtomicBool,
1909 cancellation: Option<&'a std::sync::atomic::AtomicBool>,
1910 progress: &'a BpProgress<'a>,
1911}
1912
1913fn partition_range(total: usize, level: usize, partition_id: usize) -> std::ops::Range<usize> {
1919 debug_assert!(level < usize::BITS as usize);
1920 debug_assert!(partition_id < (1usize << level));
1921 let mut start = 0usize;
1922 let mut len = total;
1923 for bit in (0..level).rev() {
1924 let left_len = len / 2;
1925 if partition_id & (1usize << bit) == 0 {
1926 len = left_len;
1927 } else {
1928 start += left_len;
1929 len -= left_len;
1930 }
1931 }
1932 start..start + len
1933}
1934
1935fn small_active_partition_set(
1939 total: usize,
1940 level: usize,
1941 min_partition_size: usize,
1942 limit: usize,
1943) -> Option<Vec<usize>> {
1944 let partition_count = 1usize.checked_shl(level as u32)?;
1945 let mut active = Vec::with_capacity(partition_count.min(limit));
1946 for partition_id in 0..partition_count {
1947 if partition_range(total, level, partition_id).len() <= min_partition_size {
1948 continue;
1949 }
1950 active.push(partition_id);
1951 if active.len() > limit {
1952 return None;
1953 }
1954 }
1955 Some(active)
1956}
1957
1958#[cfg(feature = "native")]
1965#[derive(Clone, Copy)]
1966struct LevelBuffers {
1967 docs: *mut u32,
1968 gains: *mut f32,
1969 partitioned: *mut u32,
1970 ranked: *mut usize,
1971 len: usize,
1972}
1973
1974#[cfg(feature = "native")]
1975unsafe impl Send for LevelBuffers {}
1976#[cfg(feature = "native")]
1977unsafe impl Sync for LevelBuffers {}
1978
1979#[cfg(feature = "native")]
1980impl LevelBuffers {
1981 fn new(
1982 docs: &mut [u32],
1983 gains: &mut [f32],
1984 partitioned: &mut [u32],
1985 ranked: &mut [usize],
1986 ) -> Self {
1987 debug_assert_eq!(docs.len(), gains.len());
1988 debug_assert_eq!(docs.len(), partitioned.len());
1989 debug_assert_eq!(docs.len(), ranked.len());
1990 Self {
1991 docs: docs.as_mut_ptr(),
1992 gains: gains.as_mut_ptr(),
1993 partitioned: partitioned.as_mut_ptr(),
1994 ranked: ranked.as_mut_ptr(),
1995 len: docs.len(),
1996 }
1997 }
1998
1999 unsafe fn with_slices<R>(
2004 &self,
2005 range: std::ops::Range<usize>,
2006 use_slices: impl for<'a> FnOnce(
2007 &'a mut [u32],
2008 &'a mut [f32],
2009 &'a mut [u32],
2010 &'a mut [usize],
2011 ) -> R,
2012 ) -> R {
2013 debug_assert!(range.start <= range.end && range.end <= self.len);
2014 let len = range.len();
2015 unsafe {
2018 use_slices(
2019 std::slice::from_raw_parts_mut(self.docs.add(range.start), len),
2020 std::slice::from_raw_parts_mut(self.gains.add(range.start), len),
2021 std::slice::from_raw_parts_mut(self.partitioned.add(range.start), len),
2022 std::slice::from_raw_parts_mut(self.ranked.add(range.start), len),
2023 )
2024 }
2025 }
2026}
2027
2028fn bisect_level_synchronized(
2033 docs: &mut [u32],
2034 gains: &mut [f32],
2035 partitioned: &mut [u32],
2036 ranked_scratch: &mut [usize],
2037 degree_workspaces: &mut [TermDegrees],
2038 context: &BisectContext<'_>,
2039) {
2040 debug_assert_eq!(docs.len(), gains.len());
2041 debug_assert_eq!(docs.len(), partitioned.len());
2042 debug_assert_eq!(docs.len(), ranked_scratch.len());
2043 debug_assert!(!degree_workspaces.is_empty());
2044
2045 #[cfg(feature = "native")]
2046 {
2047 let buffers = LevelBuffers::new(docs, gains, partitioned, ranked_scratch);
2048 let mut level = 0usize;
2049 while let Some(partition_count) = 1usize.checked_shl(level as u32) {
2050 let small_set = small_active_partition_set(
2051 docs.len(),
2052 level,
2053 context.min_partition_size,
2054 degree_workspaces.len(),
2055 );
2056
2057 match small_set {
2058 Some(active) if active.is_empty() => break,
2059 Some(active) => {
2060 let active_count = active.len();
2064 let allow_inner_parallelism =
2065 active_count < rayon::current_num_threads().max(1);
2066 let mut rest: &mut [TermDegrees] = &mut *degree_workspaces;
2067 let mut groups = Vec::with_capacity(active_count);
2068 for remaining_groups in (1..=active_count).rev() {
2069 let group_len = rest.len().div_ceil(remaining_groups);
2070 let (group, tail) = rest.split_at_mut(group_len);
2071 groups.push(group);
2072 rest = tail;
2073 }
2074 debug_assert!(rest.is_empty());
2075 groups.into_par_iter().zip(active.into_par_iter()).for_each(
2076 |(workspaces, partition_id)| {
2077 let range = partition_range(docs.len(), level, partition_id);
2078 unsafe {
2082 buffers.with_slices(
2083 range,
2084 |part_docs, part_gains, part_output, part_ranked| {
2085 bisect_partition(
2086 part_docs,
2087 part_gains,
2088 part_output,
2089 part_ranked,
2090 workspaces,
2091 level,
2092 allow_inner_parallelism,
2093 context,
2094 );
2095 },
2096 );
2097 }
2098 },
2099 );
2100 }
2101 None => {
2102 let next = std::sync::atomic::AtomicUsize::new(0);
2108 let allow_inner_parallelism =
2109 degree_workspaces.len() < rayon::current_num_threads().max(1);
2110 degree_workspaces.par_iter_mut().for_each(|workspace| {
2111 loop {
2112 let partition_id =
2113 next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2114 if partition_id >= partition_count {
2115 break;
2116 }
2117 let range = partition_range(docs.len(), level, partition_id);
2118 if range.len() <= context.min_partition_size {
2119 continue;
2120 }
2121 unsafe {
2125 buffers.with_slices(
2126 range,
2127 |part_docs, part_gains, part_output, part_ranked| {
2128 bisect_partition(
2129 part_docs,
2130 part_gains,
2131 part_output,
2132 part_ranked,
2133 std::slice::from_mut(workspace),
2134 level,
2135 allow_inner_parallelism,
2136 context,
2137 );
2138 },
2139 );
2140 }
2141 }
2142 });
2143 }
2144 }
2145
2146 if context.exhausted.load(std::sync::atomic::Ordering::Relaxed) {
2147 break;
2148 }
2149 level += 1;
2150 }
2151 }
2152
2153 #[cfg(not(feature = "native"))]
2154 {
2155 let mut level = 0usize;
2156 while let Some(partition_count) = 1usize.checked_shl(level as u32) {
2157 let mut active = false;
2158 for partition_id in 0..partition_count {
2159 let range = partition_range(docs.len(), level, partition_id);
2160 if range.len() <= context.min_partition_size {
2161 continue;
2162 }
2163 active = true;
2164 bisect_partition(
2165 &mut docs[range.clone()],
2166 &mut gains[range.clone()],
2167 &mut partitioned[range.clone()],
2168 &mut ranked_scratch[range],
2169 degree_workspaces,
2170 level,
2171 false,
2172 context,
2173 );
2174 }
2175 if !active || context.exhausted.load(std::sync::atomic::Ordering::Relaxed) {
2176 break;
2177 }
2178 level += 1;
2179 }
2180 }
2181}
2182
2183#[allow(clippy::too_many_arguments)]
2189fn bisect_partition(
2190 docs: &mut [u32],
2191 gains: &mut [f32],
2192 partitioned: &mut [u32],
2193 ranked_scratch: &mut [usize],
2194 degree_workspaces: &mut [TermDegrees],
2195 level: usize,
2196 allow_inner_parallelism: bool,
2197 context: &BisectContext<'_>,
2198) {
2199 let n = docs.len();
2200 debug_assert_eq!(gains.len(), n);
2201 debug_assert_eq!(partitioned.len(), n);
2202 debug_assert_eq!(ranked_scratch.len(), n);
2203 debug_assert!(!degree_workspaces.is_empty());
2204 if n <= context.min_partition_size {
2205 return;
2206 }
2207 if context.exhausted.load(std::sync::atomic::Ordering::Relaxed)
2209 || context
2210 .cancellation
2211 .is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Acquire))
2212 {
2213 context
2214 .exhausted
2215 .store(true, std::sync::atomic::Ordering::Relaxed);
2216 return;
2217 }
2218 #[cfg(feature = "native")]
2219 if let Some(dl) = context.deadline
2220 && std::time::Instant::now() >= dl
2221 {
2222 context
2223 .exhausted
2224 .store(true, std::sync::atomic::Ordering::Relaxed);
2225 return;
2226 }
2227 #[cfg(not(feature = "native"))]
2228 let _ = context.deadline;
2229
2230 context.progress.partition_started(level);
2231 let mid = n / 2;
2232
2233 let effective_iters = if n > 100_000 {
2237 context.max_iters.min(12)
2238 } else {
2239 context.max_iters
2240 };
2241
2242 build_term_degrees(
2247 docs,
2248 mid,
2249 context.fwd,
2250 degree_workspaces,
2251 context.cancellation,
2252 );
2253 if context
2254 .cancellation
2255 .is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Acquire))
2256 {
2257 context
2258 .exhausted
2259 .store(true, std::sync::atomic::Ordering::Relaxed);
2260 context.progress.partition_finished();
2261 return;
2262 }
2263 let track_objective = n >= PARALLEL_BP_MIN_ENTITIES;
2268 if track_objective {
2269 degree_workspaces[0].sort_touched_words();
2273 }
2274 let mut previous_objective = if track_objective {
2275 degree_workspaces[0].bisection_objective(mid, n - mid, context.log_table)
2276 } else {
2277 0.0
2278 };
2279 let mut best_objective = previous_objective;
2280 let mut objective_stalls = 0usize;
2281
2282 for iter in 0..effective_iters {
2283 if context
2285 .cancellation
2286 .is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Acquire))
2287 {
2288 context
2289 .exhausted
2290 .store(true, std::sync::atomic::Ordering::Relaxed);
2291 break;
2292 }
2293 #[cfg(feature = "native")]
2294 if let Some(dl) = context.deadline
2295 && std::time::Instant::now() >= dl
2296 {
2297 context
2298 .exhausted
2299 .store(true, std::sync::atomic::Ordering::Relaxed);
2300 break;
2301 }
2302 compute_gains(
2305 docs,
2306 context.fwd,
2307 mid,
2308 °ree_workspaces[0],
2309 context.log_table,
2310 gains,
2311 allow_inner_parallelism,
2312 );
2313 if context
2314 .cancellation
2315 .is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Acquire))
2316 {
2317 context
2318 .exhausted
2319 .store(true, std::sync::atomic::Ordering::Relaxed);
2320 break;
2321 }
2322
2323 let partition = partition_by_gain(
2328 docs,
2329 gains,
2330 mid,
2331 context.fwd,
2332 &mut degree_workspaces[1..],
2333 partitioned,
2334 ranked_scratch,
2335 allow_inner_parallelism,
2336 );
2337
2338 if partition.swap_count == 0 {
2339 context.progress.iteration(n, 0, 0.0, 0.0);
2340 docs.copy_from_slice(partitioned);
2344 break;
2345 }
2346
2347 match &partition.degree_update {
2348 PartitionDegreeUpdate::Moves => {
2349 let (degrees, movement_workspaces) = degree_workspaces
2350 .split_first_mut()
2351 .expect("BP always has one degree workspace");
2352 movement_workspaces
2353 .first()
2354 .expect("parallel BP movement update requires a spare workspace")
2355 .apply_moves_to(degrees);
2356 }
2357 PartitionDegreeUpdate::Ranked => update_degrees_for_ranked_partition(
2358 docs,
2359 ranked_scratch,
2360 mid,
2361 context.fwd,
2362 &mut degree_workspaces[0],
2363 ),
2364 PartitionDegreeUpdate::Threshold {
2365 threshold_key,
2366 ties_left,
2367 } => update_degrees_for_threshold_partition(
2368 docs,
2369 gains,
2370 mid,
2371 *threshold_key,
2372 *ties_left,
2373 context.fwd,
2374 &mut degree_workspaces[0],
2375 ),
2376 }
2377
2378 let (new_objective, objective_improvement, relative_improvement) = if track_objective {
2379 let new_objective =
2380 degree_workspaces[0].bisection_objective(mid, n - mid, context.log_table);
2381 let objective_improvement = new_objective - previous_objective;
2382 let relative_improvement = objective_improvement / previous_objective.abs().max(1.0);
2383 (new_objective, objective_improvement, relative_improvement)
2384 } else {
2385 (0.0, 0.0, 0.0)
2386 };
2387 context.progress.iteration(
2388 n,
2389 partition.swap_count,
2390 objective_improvement,
2391 relative_improvement,
2392 );
2393
2394 docs.copy_from_slice(partitioned);
2401 if track_objective {
2402 previous_objective = new_objective;
2403 let relative_best_improvement =
2404 (new_objective - best_objective) / best_objective.abs().max(1.0);
2405 if relative_best_improvement >= MIN_RELATIVE_OBJECTIVE_IMPROVEMENT {
2406 best_objective = new_objective;
2407 objective_stalls = 0;
2408 } else if iter + 1 >= MIN_OBJECTIVE_ITERATIONS {
2409 objective_stalls += 1;
2410 }
2411 if objective_stalls >= OBJECTIVE_STALL_ITERATIONS {
2412 context.progress.objective_stop();
2413 break;
2414 }
2415 }
2416
2417 if iter > 2 && partition.swap_count < n / 200 {
2419 break;
2420 }
2421
2422 if !track_objective && iter > 5 {
2426 let max_abs_gain = gains
2427 .iter()
2428 .copied()
2429 .fold(0.0f32, |max_gain, gain| max_gain.max(gain.abs()));
2430 if max_abs_gain < 0.001 {
2431 break;
2432 }
2433 }
2434 }
2435
2436 context.progress.partition_finished();
2437}
2438
2439#[inline(never)]
2445fn compute_gains(
2446 docs: &[u32],
2447 fwd: &ForwardIndex,
2448 mid: usize,
2449 degrees: &TermDegrees,
2450 log_table: &[f32],
2451 gains: &mut [f32],
2452 allow_inner_parallelism: bool,
2453) {
2454 let gain_for_doc = |i: usize| -> f32 {
2463 let doc = docs[i] as usize;
2464 let in_left = i < mid;
2465 let mut g = 0.0f32;
2466 for &term in fwd.doc_terms(doc) {
2467 let [left, right] = degrees.get(term as usize);
2468 let (from, to) = if in_left {
2469 (left, right)
2470 } else {
2471 (right, left)
2472 };
2473 let move_gain = fast_log2_lookup(to as usize + 2, log_table)
2474 - fast_log2_lookup(from as usize, log_table)
2475 - std::f32::consts::LOG2_E / (1.0 + to as f32);
2476 g += if in_left { move_gain } else { -move_gain };
2477 }
2478 g
2479 };
2480
2481 #[cfg(feature = "native")]
2482 {
2483 if allow_inner_parallelism && docs.len() > 4096 {
2484 gains
2485 .par_iter_mut()
2486 .enumerate()
2487 .for_each(|(i, gain)| *gain = gain_for_doc(i));
2488 } else {
2489 for (i, gain) in gains.iter_mut().enumerate().take(docs.len()) {
2490 *gain = gain_for_doc(i);
2491 }
2492 }
2493 }
2494 #[cfg(not(feature = "native"))]
2495 {
2496 for (i, gain) in gains.iter_mut().enumerate().take(docs.len()) {
2497 *gain = gain_for_doc(i);
2498 }
2499 }
2500}
2501
2502fn build_log_table(size: usize) -> Vec<f32> {
2506 let mut table = vec![0.0f32; size];
2507 table[0] = -10.0;
2509 for (i, entry) in table.iter_mut().enumerate().skip(1) {
2510 *entry = (i as f32).log2();
2511 }
2512 table
2513}
2514
2515#[inline]
2517fn fast_log2_lookup(val: usize, table: &[f32]) -> f32 {
2518 if val < table.len() {
2519 table[val]
2520 } else {
2521 (val as f32).log2()
2522 }
2523}
2524
2525#[cfg(test)]
2526mod tests {
2527 use super::*;
2528
2529 #[test]
2530 fn bounded_frequency_count_and_candidate_selection_are_exact() {
2531 let items: Vec<u32> = (0..10_000).collect();
2532 let frequencies = count_frequencies_bounded(&items, 7, 16 * 1024, |item, counts| {
2533 let term = *item as usize % counts.len();
2534 counts[term] += 1;
2535 })
2536 .unwrap();
2537 assert_eq!(frequencies.iter().sum::<u32>(), items.len() as u32);
2538 for (term, &frequency) in frequencies.iter().enumerate() {
2539 let expected = (term..items.len()).step_by(frequencies.len()).count() as u32;
2540 assert_eq!(frequency, expected);
2541 }
2542
2543 let (selected, limited) =
2544 select_frequency_candidates(&[8, 3, 0, 5, 3], 1, 10, 2 * CANDIDATE_ENTRY_BYTES);
2545 assert!(limited);
2546 let mut selected = selected;
2547 selected.sort_unstable();
2548 assert_eq!(selected, vec![(1, 3), (4, 3)]);
2549 }
2550
2551 #[test]
2552 fn bounded_frequency_count_rejects_an_undersized_budget() {
2553 assert!(
2554 count_frequencies_bounded(&[0u32], 32, 32, |_, _| {}).is_none(),
2555 "one complete dense frequency table must fit before counting"
2556 );
2557 }
2558
2559 #[test]
2560 fn lazy_term_degrees_initialize_only_on_first_write() {
2561 let mut degrees = TermDegrees::new(130);
2562 let values_ptr = degrees.values.as_ptr();
2563 let bitmap_ptr = degrees.initialized.as_ptr();
2564 assert_eq!(degrees.get(65), [0, 0]);
2565 degrees.entry_mut(65)[0] += 3;
2566 degrees.entry_mut(65)[1] += 2;
2567 assert_eq!(degrees.get(65), [3, 2]);
2568 assert_eq!(degrees.get(64), [0, 0]);
2569 assert_eq!(
2570 degrees
2571 .initialized
2572 .iter()
2573 .map(|w| w.count_ones())
2574 .sum::<u32>(),
2575 1
2576 );
2577
2578 degrees.reset();
2579 assert_eq!(degrees.values.as_ptr(), values_ptr);
2580 assert_eq!(degrees.initialized.as_ptr(), bitmap_ptr);
2581 assert_eq!(degrees.get(65), [0, 0]);
2582 assert!(degrees.touched_words.is_empty());
2583 degrees.entry_mut(129)[1] = 7;
2584 assert_eq!(degrees.get(129), [0, 7]);
2585 assert_eq!(degrees.get(65), [0, 0]);
2586 }
2587
2588 #[test]
2589 fn sparse_objective_keeps_the_original_ascending_term_order() {
2590 let mut degrees = TermDegrees::new(130);
2591 *degrees.entry_mut(129) = [5, 2];
2592 *degrees.entry_mut(1) = [3, 7];
2593 *degrees.entry_mut(65) = [11, 13];
2594 assert_eq!(degrees.touched_words, vec![2, 0, 1]);
2595 degrees.sort_touched_words();
2596 assert_eq!(degrees.touched_words, vec![0, 1, 2]);
2597
2598 let log_table = build_log_table(4096);
2599 let actual = degrees.bisection_objective(31, 32, &log_table);
2600 let side_log = [
2601 fast_log2_lookup(31, &log_table) as f64,
2602 fast_log2_lookup(32, &log_table) as f64,
2603 ];
2604 let mut original_bitmap_scan = 0.0f64;
2605 for (word_idx, &initialized) in degrees.initialized.iter().enumerate() {
2606 let mut pending = initialized;
2607 while pending != 0 {
2608 let bit = pending.trailing_zeros() as usize;
2609 let term = word_idx * 64 + bit;
2610 let [left, right] = degrees.get(term);
2611 for (side, count) in [left, right].into_iter().enumerate() {
2612 if count > 0 {
2613 original_bitmap_scan += count as f64
2614 * (fast_log2_lookup(count as usize + 1, &log_table) as f64
2615 - side_log[side]);
2616 }
2617 }
2618 pending &= pending - 1;
2619 }
2620 }
2621 assert_eq!(actual.to_bits(), original_bitmap_scan.to_bits());
2622 }
2623
2624 #[test]
2625 fn gain_radix_key_matches_total_cmp() {
2626 let values = [
2627 f32::from_bits(0xffc0_0001),
2628 f32::NEG_INFINITY,
2629 -42.0,
2630 -0.0,
2631 0.0,
2632 42.0,
2633 f32::INFINITY,
2634 f32::from_bits(0x7fc0_0001),
2635 ];
2636 let mut by_cmp = values;
2637 by_cmp.sort_by(f32::total_cmp);
2638 let mut by_key = values;
2639 by_key.sort_by_key(|value| gain_order_key(*value));
2640 assert_eq!(
2641 by_cmp.map(f32::to_bits),
2642 by_key.map(f32::to_bits),
2643 "radix selection must preserve the former total_cmp order"
2644 );
2645 }
2646
2647 #[test]
2648 fn radix_threshold_matches_exact_rank_with_ties() {
2649 let gains = [3.0, -1.0, 7.0, -1.0, 0.0, -0.0, 3.0, 9.0, 3.0, 2.0, 2.0];
2650 let mut sorted: Vec<(u32, usize)> = gains
2651 .iter()
2652 .enumerate()
2653 .map(|(idx, &gain)| (gain_order_key(gain), idx))
2654 .collect();
2655 sorted.sort_unstable();
2656
2657 for left_count in 1..=gains.len() {
2658 let (threshold, lower) = select_gain_threshold(&gains, left_count, true);
2659 assert_eq!(threshold, sorted[left_count - 1].0);
2660 assert_eq!(lower, sorted.partition_point(|&(key, _)| key < threshold),);
2661 }
2662 }
2663
2664 #[cfg(feature = "native")]
2665 #[test]
2666 fn parallel_partition_matches_exact_selection_and_degree_rebuild() {
2667 const N: usize = PARALLEL_BP_MIN_ENTITIES + 1;
2668 const TERMS: usize = 101;
2669 let mut terms = Vec::with_capacity(N * 3);
2670 let mut offsets = Vec::with_capacity(N + 1);
2671 offsets.push(0);
2672 for doc in 0..N {
2673 terms.extend_from_slice(&[
2674 (doc % TERMS) as u32,
2675 ((doc / 7) % TERMS) as u32,
2676 ((doc * 13) % TERMS) as u32,
2677 ]);
2678 offsets.push(terms.len() as u64);
2679 }
2680 let fwd = ForwardIndex {
2681 terms,
2682 offsets,
2683 num_terms: TERMS,
2684 parallel_bisect_lanes: 4,
2685 budget_limited: false,
2686 };
2687 let docs: Vec<u32> = (0..N as u32)
2688 .map(|idx| ((idx as usize * 7_919) % N) as u32)
2689 .collect();
2690 let gains: Vec<f32> = docs
2691 .iter()
2692 .map(|&doc| ((doc as usize * 37) % 257) as f32 - 128.0)
2693 .collect();
2694 let mid = N / 2;
2695
2696 let mut ranked: Vec<usize> = (0..N).collect();
2697 ranked.sort_unstable_by(|&left, &right| {
2698 gains[left]
2699 .total_cmp(&gains[right])
2700 .then_with(|| left.cmp(&right))
2701 });
2702 let mut selected_left = vec![false; N];
2703 for &idx in &ranked[..mid] {
2704 selected_left[idx] = true;
2705 }
2706 let expected: Vec<u32> = docs
2707 .iter()
2708 .enumerate()
2709 .filter(|(idx, _)| selected_left[*idx])
2710 .chain(
2711 docs.iter()
2712 .enumerate()
2713 .filter(|(idx, _)| !selected_left[*idx]),
2714 )
2715 .map(|(_, &doc)| doc)
2716 .collect();
2717
2718 let mut output = vec![0; N];
2719 let mut ranked_scratch = Vec::new();
2720 let mut movement_workspaces: Vec<_> = (0..3).map(|_| TermDegrees::new(TERMS)).collect();
2721 let outcome = partition_by_gain(
2722 &docs,
2723 &gains,
2724 mid,
2725 &fwd,
2726 &mut movement_workspaces,
2727 &mut output,
2728 &mut ranked_scratch,
2729 true,
2730 );
2731 assert_eq!(output, expected);
2732 assert!(
2733 matches!(&outcome.degree_update, PartitionDegreeUpdate::Moves),
2734 "test must exercise parallel movement counts"
2735 );
2736
2737 let mut updated_workspaces: Vec<_> = (0..4).map(|_| TermDegrees::new(TERMS)).collect();
2738 build_term_degrees(&docs, mid, &fwd, &mut updated_workspaces, None);
2739 movement_workspaces[0].apply_moves_to(&mut updated_workspaces[0]);
2740
2741 let mut rebuilt_workspaces: Vec<_> = (0..4).map(|_| TermDegrees::new(TERMS)).collect();
2742 build_term_degrees(&output, mid, &fwd, &mut rebuilt_workspaces, None);
2743 for term in 0..TERMS {
2744 assert_eq!(
2745 updated_workspaces[0].get(term),
2746 rebuilt_workspaces[0].get(term),
2747 "parallel movement-count mismatch for term {term}"
2748 );
2749 }
2750 }
2751
2752 #[test]
2758 fn test_csr_offsets_do_not_wrap_past_u32() {
2759 let counts = [1_500_000_000u32; 3]; let offsets = build_csr_offsets(&counts);
2761 assert_eq!(
2762 offsets,
2763 vec![0, 1_500_000_000, 3_000_000_000, 4_500_000_000]
2764 );
2765 assert!(*offsets.last().unwrap() > u32::MAX as u64);
2766 }
2767
2768 fn make_fwd(docs: &[&[u32]], num_terms: usize) -> ForwardIndex {
2770 let mut terms = Vec::new();
2771 let mut offsets = vec![0u64];
2772 for doc_terms in docs {
2773 terms.extend_from_slice(doc_terms);
2774 offsets.push(terms.len() as u64);
2775 }
2776 ForwardIndex {
2777 terms,
2778 offsets,
2779 num_terms,
2780 parallel_bisect_lanes: 1,
2781 budget_limited: false,
2782 }
2783 }
2784
2785 #[test]
2786 fn level_partition_ranges_match_recursive_halving() {
2787 for total in 1..=129 {
2788 let mut expected: Vec<std::ops::Range<usize>> = std::iter::once(0..total).collect();
2789 for level in 0..8 {
2790 let actual: Vec<_> = (0..(1usize << level))
2791 .map(|partition_id| partition_range(total, level, partition_id))
2792 .collect();
2793 assert_eq!(actual, expected, "total={total}, level={level}");
2794
2795 expected = expected
2796 .into_iter()
2797 .flat_map(|range| {
2798 let mid = range.start + range.len() / 2;
2799 [range.start..mid, mid..range.end]
2800 })
2801 .collect();
2802 }
2803 }
2804 }
2805
2806 #[cfg(feature = "native")]
2807 #[test]
2808 fn level_synchronized_scheduler_is_thread_count_deterministic() {
2809 let docs: Vec<Vec<u32>> = (0..1_027)
2810 .map(|doc| {
2811 vec![
2812 (doc % 31) as u32,
2813 ((doc / 5) % 53) as u32,
2814 ((doc * 29 + 3) % 97) as u32,
2815 ]
2816 })
2817 .collect();
2818 let doc_refs: Vec<_> = docs.iter().map(Vec::as_slice).collect();
2819 let mut fwd = make_fwd(&doc_refs, 97);
2820 fwd.parallel_bisect_lanes = 8;
2821 let one_thread = rayon::ThreadPoolBuilder::new()
2822 .num_threads(1)
2823 .build()
2824 .unwrap();
2825 let eight_threads = rayon::ThreadPoolBuilder::new()
2826 .num_threads(8)
2827 .build()
2828 .unwrap();
2829
2830 let one = one_thread.install(|| graph_bisection(&fwd, 8, 12, BpBudget::full()).0);
2831 let eight = eight_threads.install(|| graph_bisection(&fwd, 8, 12, BpBudget::full()).0);
2832
2833 assert_eq!(eight, one);
2834 }
2835
2836 #[cfg(feature = "native")]
2840 #[test]
2841 #[ignore]
2842 fn bench_level_synchronized_scheduler() {
2843 const DOCS: usize = 500_000;
2844 const TERMS: usize = 32_768;
2845 const ROUNDS: usize = 3;
2846
2847 let mut terms = Vec::with_capacity(DOCS * 12);
2848 let mut offsets = Vec::with_capacity(DOCS + 1);
2849 offsets.push(0);
2850 for doc in 0..DOCS {
2851 let term_count = 4 + ((doc.wrapping_mul(2_654_435_761) >> 12) % 17);
2855 for term in 0..term_count {
2856 terms.push(
2857 ((doc / 64)
2858 .wrapping_mul(131)
2859 .wrapping_add(term.wrapping_mul(7_919))
2860 % TERMS) as u32,
2861 );
2862 }
2863 offsets.push(terms.len() as u64);
2864 }
2865 let fwd = ForwardIndex {
2866 terms,
2867 offsets,
2868 num_terms: TERMS,
2869 parallel_bisect_lanes: 8,
2870 budget_limited: false,
2871 };
2872 let pool = rayon::ThreadPoolBuilder::new()
2873 .num_threads(8)
2874 .build()
2875 .unwrap();
2876 let mut level_times = Vec::with_capacity(ROUNDS);
2877 let mut expected_output = None;
2878
2879 pool.install(|| {
2880 for _ in 0..ROUNDS {
2881 let started = std::time::Instant::now();
2882 let output = graph_bisection(&fwd, 32, 12, BpBudget::full()).0;
2883 level_times.push(started.elapsed());
2884 if let Some(expected) = &expected_output {
2885 assert_eq!(&output, expected);
2886 } else {
2887 expected_output = Some(output);
2888 }
2889 }
2890 });
2891
2892 level_times.sort_unstable();
2893 let level = level_times[ROUNDS / 2];
2894 println!(
2895 "BP level-synchronized scheduler median: {:.3}s",
2896 level.as_secs_f64(),
2897 );
2898 }
2899
2900 #[test]
2901 fn test_bp_empty() {
2902 let fwd = ForwardIndex {
2903 terms: Vec::new(),
2904 offsets: Vec::new(),
2905 num_terms: 0,
2906 parallel_bisect_lanes: 1,
2907 budget_limited: false,
2908 };
2909 let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
2910 assert!(perm.is_empty());
2911 }
2912
2913 #[test]
2914 fn test_bp_small() {
2915 let fwd = make_fwd(&[&[0, 1], &[0, 2], &[1, 3], &[2, 3]], 4);
2917 let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
2918 assert_eq!(perm.len(), 4);
2919 let mut sorted = perm.clone();
2921 sorted.sort();
2922 assert_eq!(sorted, vec![0, 1, 2, 3]);
2923 }
2924
2925 #[test]
2926 fn test_bp_clusters() {
2927 let fwd = make_fwd(
2931 &[
2932 &[0, 1],
2933 &[0, 1],
2934 &[0, 1],
2935 &[0, 1],
2936 &[2, 3],
2937 &[2, 3],
2938 &[2, 3],
2939 &[2, 3],
2940 ],
2941 4,
2942 );
2943 let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
2944 assert_eq!(perm.len(), 8);
2945
2946 let left: Vec<u32> = perm[..4].to_vec();
2948
2949 let a_in_left = left.iter().filter(|&&d| d < 4).count();
2951 let b_in_left = left.iter().filter(|&&d| d >= 4).count();
2952 assert!(
2953 (a_in_left == 4 && b_in_left == 0) || (a_in_left == 0 && b_in_left == 4),
2954 "Clusters should be separated: a_left={}, b_left={}",
2955 a_in_left,
2956 b_in_left,
2957 );
2958 }
2959
2960 #[test]
2961 fn test_bp_permutation_valid() {
2962 let docs: Vec<Vec<u32>> = (0..16).map(|i| vec![i / 4, 10 + i / 2]).collect();
2964 let doc_refs: Vec<&[u32]> = docs.iter().map(|v| v.as_slice()).collect();
2965 let fwd = make_fwd(&doc_refs, 18); let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
2967
2968 assert_eq!(perm.len(), 16);
2969 let mut sorted = perm.clone();
2971 sorted.sort();
2972 let expected: Vec<u32> = (0..16).collect();
2973 assert_eq!(sorted, expected);
2974 }
2975
2976 #[test]
2981 fn test_bp_depth_cap_separates_clusters_and_converges() {
2982 let fwd = make_fwd(
2985 &[
2986 &[0, 1],
2987 &[0, 1],
2988 &[0, 1],
2989 &[2, 3],
2990 &[0, 1],
2991 &[2, 3],
2992 &[2, 3],
2993 &[2, 3],
2994 ],
2995 4,
2996 );
2997 let budget = BpBudget {
2998 min_partition_docs: Some(4),
2999 time_budget: None,
3000 };
3001 let (perm, converged) = graph_bisection(&fwd, 2, 20, budget);
3002 assert!(converged, "depth cap must report converged");
3003 assert_eq!(perm.len(), 8);
3004 let mut sorted = perm.clone();
3005 sorted.sort();
3006 assert_eq!(
3007 sorted,
3008 (0..8).collect::<Vec<u32>>(),
3009 "must stay a valid permutation"
3010 );
3011 let cluster_a = [0u32, 1, 2, 4];
3014 let a_in_left = perm[..4].iter().filter(|d| cluster_a.contains(d)).count();
3015 assert!(
3016 a_in_left == 4 || a_in_left == 0,
3017 "clusters should separate at the top level: {:?}",
3018 perm
3019 );
3020 }
3021
3022 #[test]
3025 fn test_bp_zero_time_budget_emits_valid_partial_permutation() {
3026 let docs: Vec<Vec<u32>> = (0..64).map(|i| vec![i % 4]).collect();
3027 let doc_refs: Vec<&[u32]> = docs.iter().map(|v| v.as_slice()).collect();
3028 let fwd = make_fwd(&doc_refs, 4);
3029 let budget = BpBudget {
3030 min_partition_docs: None,
3031 time_budget: Some(std::time::Duration::ZERO),
3032 };
3033 let (perm, converged) = graph_bisection(&fwd, 4, 20, budget);
3034 assert!(!converged, "zero budget must report unconverged");
3035 assert_eq!(perm.len(), 64);
3036 let mut sorted = perm.clone();
3037 sorted.sort();
3038 assert_eq!(sorted, (0..64).collect::<Vec<u32>>());
3039 }
3040
3041 #[test]
3042 fn test_bp_shutdown_cancellation_emits_valid_partial_permutation() {
3043 let docs: Vec<Vec<u32>> = (0..64).map(|i| vec![i % 4]).collect();
3044 let doc_refs: Vec<&[u32]> = docs.iter().map(Vec::as_slice).collect();
3045 let fwd = make_fwd(&doc_refs, 4);
3046 let cancellation = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
3047 let budget = BpBudget {
3048 min_partition_docs: None,
3049 time_budget: None,
3050 };
3051
3052 let (perm, converged) = graph_bisection_with_progress(
3053 &fwd,
3054 4,
3055 20,
3056 budget,
3057 Some(cancellation.as_ref()),
3058 BpProgressLabel::anonymous(),
3059 );
3060
3061 assert!(!converged, "cancelled BP must report unconverged");
3062 assert_eq!(perm, (0..64).collect::<Vec<u32>>());
3063 }
3064
3065 #[test]
3066 fn test_memory_limited_graph_never_reports_converged() {
3067 let mut fwd = make_fwd(&[&[0], &[0], &[1], &[1]], 2);
3068 fwd.budget_limited = true;
3069
3070 let (perm, converged) = graph_bisection(&fwd, 2, 20, BpBudget::full());
3071
3072 assert!(!converged);
3073 let mut sorted = perm;
3074 sorted.sort_unstable();
3075 assert_eq!(sorted, vec![0, 1, 2, 3]);
3076 }
3077
3078 #[test]
3079 fn test_fast_log2() {
3080 let table = build_log_table(4096);
3081 assert!((table[1] - 0.0).abs() < 0.001);
3082 assert!((table[2] - 1.0).abs() < 0.001);
3083 assert!((table[4] - 2.0).abs() < 0.001);
3084 assert!((table[1024] - 10.0).abs() < 0.001);
3085 let val = fast_log2_lookup(8192, &table);
3087 assert!((val - 13.0).abs() < 0.001);
3088 }
3089}