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;
26const LOG_TABLE_SIZE: usize = 4096;
27
28fn term_degree_bytes(num_terms: usize) -> usize {
29 let bitmap_words = num_terms.div_ceil(64);
30 num_terms
31 .saturating_mul(TERM_DEGREE_VALUE_BYTES)
32 .saturating_add(bitmap_words.saturating_mul(std::mem::size_of::<u64>()))
33 .saturating_add(bitmap_words.saturating_mul(std::mem::size_of::<u32>()))
37}
38
39#[cfg(feature = "native")]
44trait FrequencyParallelSafe: Sync {}
45#[cfg(feature = "native")]
46impl<T: Sync + ?Sized> FrequencyParallelSafe for T {}
47
48#[cfg(not(feature = "native"))]
49trait FrequencyParallelSafe {}
50#[cfg(not(feature = "native"))]
51impl<T: ?Sized> FrequencyParallelSafe for T {}
52
53fn count_frequencies_bounded<T: FrequencyParallelSafe>(
54 items: &[T],
55 num_terms: usize,
56 available_bytes: usize,
57 count_item: impl Fn(&T, &mut [u32]) -> crate::Result<()> + FrequencyParallelSafe,
58) -> crate::Result<Option<Vec<u32>>> {
59 if num_terms == 0 {
60 return Ok(Some(Vec::new()));
61 }
62 let Some(table_bytes) = num_terms
63 .checked_mul(std::mem::size_of::<u32>())
64 .and_then(|bytes| bytes.checked_add(std::mem::size_of::<Vec<u32>>()))
65 else {
66 return Ok(None);
67 };
68 let affordable_tables = available_bytes / table_bytes;
69 if affordable_tables == 0 {
70 return Ok(None);
71 }
72
73 #[cfg(feature = "native")]
74 {
75 let lanes = affordable_tables
76 .min(rayon::current_num_threads().max(1))
77 .min(items.len().max(1));
78 if lanes > 1 {
79 let chunk_len = items.len().div_ceil(lanes);
80 return items
81 .par_chunks(chunk_len)
82 .map(|chunk| {
83 let mut counts = vec![0u32; num_terms];
84 for item in chunk {
85 count_item(item, &mut counts)?;
86 }
87 Ok(counts)
88 })
89 .try_reduce_with(|mut left, right| {
90 for (total, count) in left.iter_mut().zip(right) {
91 *total = total.saturating_add(count);
92 }
93 Ok(left)
94 })
95 .transpose();
96 }
97 }
98
99 let mut counts = vec![0u32; num_terms];
100 for item in items {
101 count_item(item, &mut counts)?;
102 }
103 Ok(Some(counts))
104}
105
106pub(crate) fn select_frequency_candidates(
109 frequencies: &[u32],
110 min_frequency: usize,
111 max_frequency: usize,
112 candidate_budget_bytes: usize,
113) -> (Vec<(u32, usize)>, bool) {
114 let eligible_count = frequencies
115 .iter()
116 .filter(|&&frequency| {
117 let frequency = frequency as usize;
118 frequency >= min_frequency && frequency <= max_frequency
119 })
120 .count();
121 let capacity = candidate_budget_bytes
122 .checked_div(CANDIDATE_ENTRY_BYTES)
123 .unwrap_or(0)
124 .min(eligible_count);
125 let mut candidates = std::collections::BinaryHeap::with_capacity(capacity);
126 for (term_id, &frequency) in frequencies.iter().enumerate() {
127 let frequency = frequency as usize;
128 if frequency < min_frequency || frequency > max_frequency {
129 continue;
130 }
131 let candidate = (frequency, term_id as u32);
132 if candidates.len() < capacity {
133 candidates.push(candidate);
134 } else if capacity > 0 && candidate < *candidates.peek().unwrap() {
135 candidates.pop();
136 candidates.push(candidate);
137 }
138 }
139 let selected = candidates
140 .into_vec()
141 .into_iter()
142 .map(|(frequency, term_id)| (term_id, frequency))
143 .collect();
144 (selected, capacity < eligible_count)
145}
146
147pub(crate) struct CandidateFit {
148 pub(crate) estimated_bytes: usize,
149 pub(crate) retained_postings: usize,
150 pub(crate) dropped: usize,
151}
152
153pub(crate) fn fit_candidates_to_budget(
157 candidates: &mut Vec<(u32, usize)>,
158 fixed_bytes: usize,
159 memory_budget_bytes: usize,
160 posting_bytes: usize,
161) -> CandidateFit {
162 let total_postings = candidates.iter().fold(0usize, |total, (_, frequency)| {
163 total.saturating_add(*frequency)
164 });
165 let estimated_bytes = total_postings
166 .saturating_mul(posting_bytes)
167 .saturating_add(fixed_bytes)
168 .saturating_add(candidates.len().saturating_mul(CANDIDATE_ENTRY_BYTES))
169 .saturating_add(term_degree_bytes(candidates.len()));
170 if estimated_bytes <= memory_budget_bytes || candidates.is_empty() {
171 return CandidateFit {
172 estimated_bytes,
173 retained_postings: total_postings,
174 dropped: 0,
175 };
176 }
177
178 candidates.sort_by_key(|&(_, frequency)| frequency);
179 let mut used_bytes = fixed_bytes;
180 let mut retained_postings = 0usize;
181 let mut keep = 0usize;
182 for &(_, frequency) in candidates.iter() {
183 let term_bytes = frequency
184 .saturating_mul(posting_bytes)
185 .saturating_add(TERM_DEGREE_VALUE_BYTES + 1)
186 .saturating_add(CANDIDATE_ENTRY_BYTES);
187 if term_bytes > memory_budget_bytes.saturating_sub(used_bytes) {
188 break;
189 }
190 used_bytes = used_bytes.saturating_add(term_bytes);
191 retained_postings = retained_postings.saturating_add(frequency);
192 keep += 1;
193 }
194 let dropped = candidates.len() - keep;
195 candidates.truncate(keep);
196 candidates.shrink_to_fit();
199 CandidateFit {
200 estimated_bytes,
201 retained_postings,
202 dropped,
203 }
204}
205
206fn parallel_bisect_lanes(
207 memory_budget_bytes: usize,
208 non_degree_bytes: usize,
209 num_terms: usize,
210) -> usize {
211 let per_node = term_degree_bytes(num_terms).max(1);
212 let affordable_nodes = memory_budget_bytes
213 .saturating_sub(non_degree_bytes)
214 .checked_div(per_node)
215 .unwrap_or(0)
216 .max(1);
217 #[cfg(feature = "native")]
218 let worker_limit = rayon::current_num_threads().max(1);
219 #[cfg(not(feature = "native"))]
220 let worker_limit = 1usize;
221 affordable_nodes.min(worker_limit)
222}
223
224fn gain_cache_fits(budget: usize, non_degree_bytes: usize, terms: usize, lanes: usize) -> bool {
228 let required = terms
229 .checked_mul(std::mem::size_of::<[f32; 2]>())
230 .and_then(|cache| cache.checked_add(term_degree_bytes(terms)))
231 .and_then(|lane| lane.checked_add(std::mem::size_of::<TermDegrees>()))
232 .and_then(|lane| lane.checked_mul(lanes))
233 .and_then(|all_lanes| all_lanes.checked_add(non_degree_bytes))
234 .and_then(|graph| graph.checked_add(LOG_TABLE_SIZE * std::mem::size_of::<f32>()));
235 terms > 0 && required.is_some_and(|bytes| bytes <= budget)
236}
237
238struct TermDegrees {
246 values: Vec<std::mem::MaybeUninit<[u32; 2]>>,
247 initialized: Vec<u64>,
248 touched_words: Vec<u32>,
249 gain_cache: Vec<[f32; 2]>,
252}
253
254impl TermDegrees {
255 fn new(num_terms: usize) -> Self {
256 let bitmap_words = num_terms.div_ceil(64);
257 let mut values = Vec::with_capacity(num_terms);
258 values.resize_with(num_terms, std::mem::MaybeUninit::uninit);
259 Self {
260 values,
261 initialized: vec![0; bitmap_words],
262 touched_words: Vec::with_capacity(bitmap_words),
263 gain_cache: Vec::new(),
264 }
265 }
266
267 fn reset(&mut self) {
270 for word in self.touched_words.drain(..) {
271 self.initialized[word as usize] = 0;
272 }
273 }
274
275 fn sort_touched_words(&mut self) {
276 self.touched_words.sort_unstable();
277 }
278
279 #[inline]
280 fn entry_mut(&mut self, term: usize) -> &mut [u32; 2] {
281 let word = term / 64;
282 let mask = 1u64 << (term % 64);
283 if self.initialized[word] & mask == 0 {
284 if self.initialized[word] == 0 {
285 self.touched_words.push(word as u32);
286 }
287 self.values[term].write([0, 0]);
288 self.initialized[word] |= mask;
289 }
290 unsafe { self.values[term].assume_init_mut() }
292 }
293
294 #[inline]
295 fn get(&self, term: usize) -> [u32; 2] {
296 let word = term / 64;
297 let mask = 1u64 << (term % 64);
298 if self.initialized[word] & mask == 0 {
299 return [0, 0];
300 }
301 unsafe { *self.values[term].assume_init_ref() }
304 }
305
306 fn refresh_gain_cache(&mut self, log_table: &[f32]) {
307 if self.gain_cache.is_empty() {
308 return;
309 }
310 for &word_idx in &self.touched_words {
311 let word_idx = word_idx as usize;
312 let mut pending = self.initialized[word_idx];
313 while pending != 0 {
314 let bit = pending.trailing_zeros() as usize;
315 let term = word_idx * 64 + bit;
316 let [left, right] = unsafe { *self.values[term].assume_init_ref() };
318 self.gain_cache[term] = [
322 if left == 0 {
323 0.0
324 } else {
325 fast_log2_lookup(right as usize + 2, log_table)
326 - fast_log2_lookup(left as usize, log_table)
327 - std::f32::consts::LOG2_E / (1.0 + right as f32)
328 },
329 if right == 0 {
330 0.0
331 } else {
332 -(fast_log2_lookup(left as usize + 2, log_table)
333 - fast_log2_lookup(right as usize, log_table)
334 - std::f32::consts::LOG2_E / (1.0 + left as f32))
335 },
336 ];
337 pending &= pending - 1;
338 }
339 }
340 }
341
342 fn merge_from(&mut self, other: &Self) {
343 for &word_idx in &other.touched_words {
344 let word_idx = word_idx as usize;
345 let mut pending = other.initialized[word_idx];
346 while pending != 0 {
347 let bit = pending.trailing_zeros() as usize;
348 let term = word_idx * 64 + bit;
349 let [left, right] = unsafe { *other.values[term].assume_init_ref() };
351 let entry = self.entry_mut(term);
352 entry[0] += left;
353 entry[1] += right;
354 pending &= pending - 1;
355 }
356 }
357 }
358
359 fn apply_moves_to(&self, degrees: &mut Self) {
365 for &word_idx in &self.touched_words {
366 let word_idx = word_idx as usize;
367 let mut pending = self.initialized[word_idx];
368 while pending != 0 {
369 let bit = pending.trailing_zeros() as usize;
370 let term = word_idx * 64 + bit;
371 let [right_to_left, left_to_right] =
373 unsafe { *self.values[term].assume_init_ref() };
374 debug_assert!(
375 degrees.initialized[word_idx] & (1u64 << bit) != 0,
376 "a moved term must already exist in the partition degrees"
377 );
378 let degree = degrees.entry_mut(term);
379 let new_left =
380 i64::from(degree[0]) + i64::from(right_to_left) - i64::from(left_to_right);
381 let new_right =
382 i64::from(degree[1]) + i64::from(left_to_right) - i64::from(right_to_left);
383 debug_assert!(new_left >= 0 && new_right >= 0);
384 debug_assert!(new_left <= i64::from(u32::MAX));
385 debug_assert!(new_right <= i64::from(u32::MAX));
386 degree[0] = new_left as u32;
387 degree[1] = new_right as u32;
388 pending &= pending - 1;
389 }
390 }
391 }
392
393 fn bisection_objective(&self, left_size: usize, right_size: usize, log_table: &[f32]) -> f64 {
400 let mut objective = 0.0f64;
401 let side_log = [
402 fast_log2_lookup(left_size, log_table) as f64,
403 fast_log2_lookup(right_size, log_table) as f64,
404 ];
405 for &word_idx in &self.touched_words {
406 let word_idx = word_idx as usize;
407 let mut pending = self.initialized[word_idx];
408 while pending != 0 {
409 let bit = pending.trailing_zeros() as usize;
410 let term = word_idx * 64 + bit;
411 let [left, right] = unsafe { *self.values[term].assume_init_ref() };
413 for (side, count) in [left, right].into_iter().enumerate() {
414 if count > 0 {
415 objective += count as f64
416 * (fast_log2_lookup(count as usize + 1, log_table) as f64
417 - side_log[side]);
418 }
419 }
420 pending &= pending - 1;
421 }
422 }
423 objective
424 }
425}
426
427pub(crate) struct ForwardIndex {
433 terms: Vec<u32>,
434 offsets: Vec<u64>,
439 pub num_terms: usize,
440 parallel_bisect_lanes: usize,
444 cache_gains: bool,
445 budget_limited: bool,
449}
450
451fn build_csr_offsets(
454 counts: &[u32],
455 check_cancel: &(impl Fn() -> crate::Result<()> + Sync),
456) -> crate::Result<Vec<u64>> {
457 let mut offsets = Vec::with_capacity(counts.len() + 1);
458 offsets.push(0u64);
459 for (i, &c) in counts.iter().enumerate() {
460 if i.is_multiple_of(256) {
461 check_cancel()?;
462 }
463 offsets.push(offsets.last().unwrap() + c as u64);
464 }
465 Ok(offsets)
466}
467
468impl ForwardIndex {
469 pub(crate) fn from_csr(
473 terms: Vec<u32>,
474 offsets: Vec<u64>,
475 num_terms: usize,
476 memory_budget_bytes: usize,
477 budget_limited: bool,
478 ) -> Self {
479 let non_degree_bytes = terms
480 .len()
481 .saturating_mul(std::mem::size_of::<u32>())
482 .saturating_add(offsets.len().saturating_sub(1).saturating_mul(32));
483 let lanes = parallel_bisect_lanes(memory_budget_bytes, non_degree_bytes, num_terms);
484 Self {
485 terms,
486 offsets,
487 num_terms,
488 parallel_bisect_lanes: lanes.max(1),
489 cache_gains: false,
493 budget_limited,
494 }
495 }
496
497 #[inline]
498 pub fn num_docs(&self) -> usize {
499 if self.offsets.is_empty() {
500 0
501 } else {
502 self.offsets.len() - 1
503 }
504 }
505
506 #[inline]
507 fn doc_terms(&self, doc: usize) -> &[u32] {
508 let start = self.offsets[doc] as usize;
509 let end = self.offsets[doc + 1] as usize;
510 &self.terms[start..end]
511 }
512
513 pub fn total_postings(&self) -> u64 {
515 self.offsets.last().copied().unwrap_or(0)
516 }
517
518 #[inline]
519 pub fn budget_limited(&self) -> bool {
520 self.budget_limited
521 }
522}
523
524pub(crate) fn build_vid_maps(
534 bmp: &crate::segment::reader::bmp::BmpIndex,
535 check_cancel: &(impl Fn() -> crate::Result<()> + Sync),
536) -> crate::Result<(Vec<u32>, Vec<u32>)> {
537 check_cancel()?;
538 let ids = bmp.doc_map_ids_slice();
539 let num_virtual = bmp.num_virtual_docs as usize;
540 let expected_real = bmp.num_real_docs() as usize;
541 let mut virtual_to_real = vec![u32::MAX; num_virtual];
542 let mut real_to_virtual = Vec::with_capacity(expected_real);
543 debug_assert_eq!(ids.len(), virtual_to_real.len() * 4);
544 bmp.visit_real_slots_for_rewrite(check_cancel, |vid| {
545 virtual_to_real[vid] = real_to_virtual.len() as u32;
546 real_to_virtual.push(vid as u32);
547 })?;
548 Ok((virtual_to_real, real_to_virtual))
549}
550
551struct BlockJob {
557 src: u32,
558 block_id: u32,
559 real_start: u32,
561 real_len: u32,
563}
564
565fn build_block_jobs(
568 bmps: &[&crate::segment::reader::bmp::BmpIndex],
569 vid_maps: &[(Vec<u32>, Vec<u32>)],
570 check_cancel: &(impl Fn() -> crate::Result<()> + Sync),
571) -> crate::Result<Vec<BlockJob>> {
572 let total_blocks: usize = bmps.iter().map(|b| b.num_blocks as usize).sum();
573 let mut jobs = Vec::with_capacity(total_blocks);
574 for (src, (bmp, (v2r, _))) in bmps.iter().zip(vid_maps).enumerate() {
575 let block_size = bmp.bmp_block_size as usize;
576 let mut real_cursor = 0u32;
577 for block_id in 0..bmp.num_blocks as usize {
578 check_cancel()?;
579 let vid_start = block_id * block_size;
580 let vid_end = ((block_id + 1) * block_size).min(v2r.len());
581 let real_len = v2r[vid_start..vid_end]
582 .iter()
583 .filter(|&&r| r != u32::MAX)
584 .count() as u32;
585 jobs.push(BlockJob {
586 src: src as u32,
587 block_id: block_id as u32,
588 real_start: real_cursor,
589 real_len,
590 });
591 real_cursor += real_len;
592 }
593 }
594 Ok(jobs)
595}
596
597fn visit_bmp_job(
598 job: &BlockJob,
599 bmps: &[&crate::segment::reader::bmp::BmpIndex],
600 vid_maps: &[(Vec<u32>, Vec<u32>)],
601 forward_views: &[Option<crate::segment::bmp_forward::ValidatedForward<'_>>],
602 mut visit: impl FnMut(u32, u32),
603 check_cancel: &(impl Fn() -> crate::Result<()> + Sync),
604) -> crate::Result<()> {
605 check_cancel()?;
606 let bmp = bmps[job.src as usize];
607 let (v2r, r2v) = &vid_maps[job.src as usize];
608 if let Some(view) = &forward_views[job.src as usize] {
609 let forward = bmp.forward().expect("validated forward source");
610 for real in job.real_start..job.real_start + job.real_len {
611 check_cancel()?;
612 let (doc, ordinal) = bmp.virtual_to_doc(r2v[real as usize]);
613 let index = forward
614 .find(crate::segment::logical_address::LogicalUnit { doc, ordinal })
615 .expect("validated forward key");
616 for (dim, _) in view.vector(index).iter() {
617 visit(real, dim);
618 }
619 }
620 } else {
621 for (dim, _, postings) in bmp.iter_block_terms(job.block_id) {
622 check_cancel()?;
623 for posting in postings {
624 let vid = job.block_id as usize * bmp.bmp_block_size as usize
625 + posting.local_slot as usize;
626 if let Some(&real) = v2r.get(vid)
627 && real != u32::MAX
628 && posting.impact > 0
629 {
630 visit(real, dim);
631 }
632 }
633 }
634 }
635 Ok(())
636}
637
638#[cfg(test)]
652pub(crate) fn build_forward_index_from_bmps(
653 bmps: &[&crate::segment::reader::bmp::BmpIndex],
654 min_doc_freq: usize,
655 max_doc_freq: usize,
656 memory_budget_bytes: usize,
657) -> crate::Result<ForwardIndex> {
658 let vid_maps: Vec<(Vec<u32>, Vec<u32>)> = bmps
659 .iter()
660 .map(|bmp| build_vid_maps(bmp, &|| Ok(())))
661 .collect::<crate::Result<_>>()?;
662 build_forward_index_from_bmps_with_maps(
663 bmps,
664 &vid_maps,
665 min_doc_freq,
666 max_doc_freq,
667 memory_budget_bytes,
668 &|| Ok(()),
669 )
670}
671
672pub(crate) fn build_forward_index_from_bmps_with_maps(
676 bmps: &[&crate::segment::reader::bmp::BmpIndex],
677 vid_maps: &[(Vec<u32>, Vec<u32>)],
678 min_doc_freq: usize,
679 max_doc_freq: usize,
680 memory_budget_bytes: usize,
681 check_cancel: &(impl Fn() -> crate::Result<()> + Sync),
682) -> crate::Result<ForwardIndex> {
683 check_cancel()?;
684 debug_assert_eq!(bmps.len(), vid_maps.len());
685 let total_docs: usize = vid_maps.iter().map(|(_, r2v)| r2v.len()).sum();
686
687 if total_docs == 0 {
688 return Ok(ForwardIndex {
689 terms: Vec::new(),
690 offsets: Vec::new(),
691 num_terms: 0,
692 parallel_bisect_lanes: 1,
693 cache_gains: false,
694 budget_limited: false,
695 });
696 }
697
698 let jobs = build_block_jobs(bmps, vid_maps, check_cancel)?;
703 check_cancel()?;
704 let forward_views = bmps
705 .iter()
706 .zip(vid_maps)
707 .map(|(bmp, (_, r2v))| {
708 let Some(forward) = bmp.forward() else {
709 return Ok(None);
710 };
711 for &vid in r2v {
712 check_cancel()?;
713 let (doc, ordinal) = bmp.virtual_to_doc(vid);
714 if forward
715 .find(crate::segment::logical_address::LogicalUnit { doc, ordinal })
716 .is_none()
717 {
718 return Err(crate::Error::Corruption(
719 "BMP physical vector missing from forward values".into(),
720 ));
721 }
722 }
723 forward.validate_payload(check_cancel).map(Some)
724 })
725 .collect::<crate::Result<Vec<_>>>()?;
726
727 let max_dims = bmps
731 .iter()
732 .map(|bmp| bmp.dims() as usize)
733 .max()
734 .unwrap_or(0);
735 let jobs_bytes = jobs
736 .len()
737 .saturating_mul(std::mem::size_of::<BlockJob>().saturating_add(40));
738 let frequency_bytes = max_dims.saturating_mul(std::mem::size_of::<u32>());
739 if frequency_bytes > memory_budget_bytes.saturating_sub(jobs_bytes) {
740 log::warn!(
741 "[reorder] memory budget {} cannot hold the {} dimension-frequency table; using identity order",
742 crate::format_bytes(memory_budget_bytes as u64),
743 crate::format_bytes(frequency_bytes as u64),
744 );
745 return Ok(ForwardIndex {
746 terms: Vec::new(),
747 offsets: Vec::new(),
748 num_terms: 0,
749 parallel_bisect_lanes: 1,
750 cache_gains: false,
751 budget_limited: true,
752 });
753 }
754 let Some(dim_df) = count_frequencies_bounded(
755 &jobs,
756 max_dims,
757 memory_budget_bytes.saturating_sub(jobs_bytes),
758 |job, counts| {
759 visit_bmp_job(
760 job,
761 bmps,
762 vid_maps,
763 &forward_views,
764 |_, dim| {
765 if let Some(total) = counts.get_mut(dim as usize) {
766 *total = total.saturating_add(1);
767 }
768 },
769 check_cancel,
770 )
771 },
772 )?
773 else {
774 log::warn!(
775 "[reorder] memory budget {} cannot hold a bounded dimension-frequency table; using identity order",
776 crate::format_bytes(memory_budget_bytes as u64),
777 );
778 return Ok(ForwardIndex {
779 terms: Vec::new(),
780 offsets: Vec::new(),
781 num_terms: 0,
782 parallel_bisect_lanes: 1,
783 cache_gains: false,
784 budget_limited: true,
785 });
786 };
787
788 let (mut eligible, mut budget_limited) = select_frequency_candidates(
792 &dim_df,
793 min_doc_freq,
794 max_doc_freq,
795 memory_budget_bytes
796 .saturating_sub(jobs_bytes)
797 .saturating_sub(frequency_bytes),
798 );
799 drop(dim_df);
800
801 let entity_scratch_bytes = total_docs.saturating_mul(32);
805 let remap_bytes = max_dims.saturating_mul(4);
806 let fixed_bytes = entity_scratch_bytes
807 .saturating_add(remap_bytes)
808 .saturating_add(jobs_bytes);
809 let fit = fit_candidates_to_budget(
810 &mut eligible,
811 fixed_bytes,
812 memory_budget_bytes,
813 std::mem::size_of::<u32>(),
814 );
815 if fit.dropped > 0 {
816 budget_limited = true;
817 log::warn!(
818 "[reorder] memory budget {}: estimated {}, dropped {} highest-df dims, keeping {} ({} postings)",
819 crate::format_bytes(memory_budget_bytes as u64),
820 crate::format_bytes(fit.estimated_bytes as u64),
821 fit.dropped,
822 eligible.len(),
823 fit.retained_postings,
824 );
825 }
826
827 if eligible.is_empty() {
828 return Ok(ForwardIndex {
833 terms: Vec::new(),
834 offsets: Vec::new(),
835 num_terms: 0,
836 parallel_bisect_lanes: 1,
837 cache_gains: false,
838 budget_limited,
839 });
840 }
841
842 check_cancel()?;
843 let mut term_remap = vec![u32::MAX; max_dims];
844 for (compact_id, &(dim_id, _)) in eligible.iter().enumerate() {
845 term_remap[dim_id as usize] = compact_id as u32;
846 }
847 let num_active_terms = eligible.len();
848 let non_degree_bytes = entity_scratch_bytes.saturating_add(
853 fit.retained_postings
854 .saturating_mul(std::mem::size_of::<u32>()),
855 );
856 let parallel_bisect_lanes =
857 parallel_bisect_lanes(memory_budget_bytes, non_degree_bytes, num_active_terms);
858 drop(eligible);
859
860 let mut counts = vec![0u32; total_docs];
862 let fill_block_counts = |job: &BlockJob, out: &mut [u32]| {
863 visit_bmp_job(
864 job,
865 bmps,
866 vid_maps,
867 &forward_views,
868 |real, dim| {
869 if term_remap.get(dim as usize).copied().unwrap_or(u32::MAX) != u32::MAX {
870 out[(real - job.real_start) as usize] += 1;
871 }
872 },
873 check_cancel,
874 )
875 };
876 {
877 let mut slices: Vec<(&BlockJob, &mut [u32])> = Vec::with_capacity(jobs.len());
878 let mut rest: &mut [u32] = &mut counts;
879 for job in &jobs {
880 check_cancel()?;
881 let (head, tail) = rest.split_at_mut(job.real_len as usize);
882 slices.push((job, head));
883 rest = tail;
884 }
885 #[cfg(feature = "native")]
886 slices
887 .into_par_iter()
888 .try_for_each(|(job, out)| fill_block_counts(job, out))?;
889 #[cfg(not(feature = "native"))]
890 for (job, out) in slices {
891 fill_block_counts(job, out)?;
892 }
893 }
894
895 check_cancel()?;
897 let offsets = build_csr_offsets(&counts, check_cancel)?;
898 check_cancel()?;
899 let total = *offsets.last().unwrap() as usize;
900 drop(counts);
901
902 let mut terms = vec![0u32; total];
905 let fill_block_terms = |job: &BlockJob, global_real_start: usize, out: &mut [u32]| {
906 let mut cursor = [0u32; 256];
907 let base = offsets[global_real_start] as usize;
908 visit_bmp_job(
909 job,
910 bmps,
911 vid_maps,
912 &forward_views,
913 |real, dim| {
914 let compact = term_remap.get(dim as usize).copied().unwrap_or(u32::MAX);
915 if compact != u32::MAX {
916 let local = (real - job.real_start) as usize;
917 let pos =
918 offsets[global_real_start + local] as usize - base + cursor[local] as usize;
919 out[pos] = compact;
920 cursor[local] += 1;
921 }
922 },
923 check_cancel,
924 )
925 };
926 {
927 let mut slices: Vec<(&BlockJob, usize, &mut [u32])> = Vec::with_capacity(jobs.len());
928 let mut rest: &mut [u32] = &mut terms;
929 let mut global_real = 0usize;
930 for job in &jobs {
931 check_cancel()?;
932 let len =
933 (offsets[global_real + job.real_len as usize] - offsets[global_real]) as usize;
934 let (head, tail) = rest.split_at_mut(len);
935 slices.push((job, global_real, head));
936 rest = tail;
937 global_real += job.real_len as usize;
938 }
939 #[cfg(feature = "native")]
940 slices
941 .into_par_iter()
942 .try_for_each(|(job, g, out)| fill_block_terms(job, g, out))?;
943 #[cfg(not(feature = "native"))]
944 for (job, g, out) in slices {
945 fill_block_terms(job, g, out)?;
946 }
947 }
948
949 check_cancel()?;
950 Ok(ForwardIndex {
951 terms,
952 offsets,
953 num_terms: num_active_terms,
954 parallel_bisect_lanes,
955 cache_gains: gain_cache_fits(
956 memory_budget_bytes,
957 non_degree_bytes,
958 num_active_terms,
959 parallel_bisect_lanes,
960 ),
961 budget_limited,
962 })
963}
964
965pub(crate) fn build_forward_index_from_blocks(
973 bmps: &[&crate::segment::reader::bmp::BmpIndex],
974 memory_budget_bytes: usize,
975 check_cancel: &(impl Fn() -> crate::Result<()> + Sync),
976) -> crate::Result<ForwardIndex> {
977 check_cancel()?;
978 let total_blocks: usize = bmps.iter().map(|b| b.num_blocks as usize).sum();
979 if total_blocks == 0 {
980 return Ok(ForwardIndex {
981 terms: Vec::new(),
982 offsets: Vec::new(),
983 num_terms: 0,
984 parallel_bisect_lanes: 1,
985 cache_gains: false,
986 budget_limited: false,
987 });
988 }
989
990 let blocks: Vec<(u32, u32)> = bmps
992 .iter()
993 .enumerate()
994 .flat_map(|(src, bmp)| {
995 (0..bmp.num_blocks).map(move |b| {
996 check_cancel()?;
997 Ok((src as u32, b))
998 })
999 })
1000 .collect::<crate::Result<_>>()?;
1001
1002 let max_dims = bmps
1005 .iter()
1006 .map(|bmp| bmp.dims() as usize)
1007 .max()
1008 .unwrap_or(0);
1009 let blocks_bytes = blocks
1010 .len()
1011 .saturating_mul(std::mem::size_of::<(u32, u32)>().saturating_add(32));
1012 let frequency_bytes = max_dims.saturating_mul(std::mem::size_of::<u32>());
1013 if frequency_bytes > memory_budget_bytes.saturating_sub(blocks_bytes) {
1014 log::warn!(
1015 "[reorder] block-level frequency table exceeds memory budget; using identity order"
1016 );
1017 return Ok(ForwardIndex {
1018 terms: Vec::new(),
1019 offsets: Vec::new(),
1020 num_terms: 0,
1021 parallel_bisect_lanes: 1,
1022 cache_gains: false,
1023 budget_limited: true,
1024 });
1025 }
1026 let Some(dim_bf) = count_frequencies_bounded(
1027 &blocks,
1028 max_dims,
1029 memory_budget_bytes.saturating_sub(blocks_bytes),
1030 |&(src, block_id), counts| {
1031 check_cancel()?;
1032 for (dim_id, _, _) in bmps[src as usize].iter_block_terms(block_id) {
1033 check_cancel()?;
1034 if let Some(count) = counts.get_mut(dim_id as usize) {
1035 *count = count.saturating_add(1);
1036 }
1037 }
1038 Ok(())
1039 },
1040 )?
1041 else {
1042 log::warn!(
1043 "[reorder] block-level frequency table cannot fit its bounded allocation; using identity order"
1044 );
1045 return Ok(ForwardIndex {
1046 terms: Vec::new(),
1047 offsets: Vec::new(),
1048 num_terms: 0,
1049 parallel_bisect_lanes: 1,
1050 cache_gains: false,
1051 budget_limited: true,
1052 });
1053 };
1054
1055 let max_bf = (total_blocks as f64 * 0.9) as usize;
1056 let (mut eligible, mut budget_limited) = select_frequency_candidates(
1057 &dim_bf,
1058 2,
1059 max_bf.max(2),
1060 memory_budget_bytes
1061 .saturating_sub(blocks_bytes)
1062 .saturating_sub(frequency_bytes),
1063 );
1064 drop(dim_bf);
1065
1066 let entity_scratch_bytes = total_blocks.saturating_mul(32);
1067 let remap_bytes = max_dims.saturating_mul(4);
1068 let fixed_bytes = entity_scratch_bytes
1069 .saturating_add(remap_bytes)
1070 .saturating_add(blocks_bytes);
1071 let fit = fit_candidates_to_budget(
1072 &mut eligible,
1073 fixed_bytes,
1074 memory_budget_bytes,
1075 std::mem::size_of::<u32>(),
1076 );
1077 if fit.dropped > 0 {
1078 budget_limited = true;
1079 log::warn!(
1080 "[reorder] block-level fwd index over budget — dropped {} highest-bf dims",
1081 fit.dropped,
1082 );
1083 }
1084
1085 if eligible.is_empty() {
1086 return Ok(ForwardIndex {
1087 terms: Vec::new(),
1088 offsets: Vec::new(),
1089 num_terms: 0,
1090 parallel_bisect_lanes: 1,
1091 cache_gains: false,
1092 budget_limited,
1093 });
1094 }
1095
1096 let mut term_remap = vec![u32::MAX; max_dims];
1097 for (compact, &(dim_id, _)) in eligible.iter().enumerate() {
1098 term_remap[dim_id as usize] = compact as u32;
1099 }
1100 let num_terms = eligible.len();
1101 let non_degree_bytes = entity_scratch_bytes.saturating_add(
1102 fit.retained_postings
1103 .saturating_mul(std::mem::size_of::<u32>()),
1104 );
1105 let parallel_bisect_lanes =
1106 parallel_bisect_lanes(memory_budget_bytes, non_degree_bytes, num_terms);
1107 drop(eligible);
1108
1109 let count_remapped = |&(src, block_id): &(u32, u32)| -> crate::Result<u32> {
1112 check_cancel()?;
1113 let mut count = 0;
1114 for (dim_id, _, _) in bmps[src as usize].iter_block_terms(block_id) {
1115 check_cancel()?;
1116 check_cancel()?;
1117 if term_remap.get(dim_id as usize).copied().unwrap_or(u32::MAX) != u32::MAX {
1118 count += 1;
1119 }
1120 }
1121 Ok(count)
1122 };
1123 #[cfg(feature = "native")]
1124 let counts: Vec<u32> = blocks
1125 .par_iter()
1126 .map(count_remapped)
1127 .collect::<crate::Result<_>>()?;
1128 #[cfg(not(feature = "native"))]
1129 let counts: Vec<u32> = blocks
1130 .iter()
1131 .map(count_remapped)
1132 .collect::<crate::Result<_>>()?;
1133
1134 let offsets = build_csr_offsets(&counts, check_cancel)?;
1135 let total = *offsets.last().unwrap() as usize;
1136 drop(counts);
1137
1138 let mut terms = vec![0u32; total];
1139 let fill_block = |&(src, block_id): &(u32, u32), out: &mut [u32]| -> crate::Result<()> {
1140 check_cancel()?;
1141 let mut n = 0usize;
1142 for (dim_id, _, _) in bmps[src as usize].iter_block_terms(block_id) {
1143 check_cancel()?;
1144 let compact = term_remap.get(dim_id as usize).copied().unwrap_or(u32::MAX);
1145 if compact != u32::MAX {
1146 out[n] = compact;
1147 n += 1;
1148 }
1149 }
1150 Ok(())
1151 };
1152 {
1153 let mut slices: Vec<(&(u32, u32), &mut [u32])> = Vec::with_capacity(blocks.len());
1154 let mut rest: &mut [u32] = &mut terms;
1155 for (gb, b) in blocks.iter().enumerate() {
1156 check_cancel()?;
1157 let len = (offsets[gb + 1] - offsets[gb]) as usize;
1158 let (head, tail) = rest.split_at_mut(len);
1159 slices.push((b, head));
1160 rest = tail;
1161 }
1162 #[cfg(feature = "native")]
1163 slices
1164 .into_par_iter()
1165 .try_for_each(|(b, out)| fill_block(b, out))?;
1166 #[cfg(not(feature = "native"))]
1167 for (b, out) in slices {
1168 fill_block(b, out)?;
1169 }
1170 }
1171
1172 check_cancel()?;
1173 Ok(ForwardIndex {
1174 terms,
1175 offsets,
1176 num_terms,
1177 parallel_bisect_lanes,
1178 cache_gains: gain_cache_fits(
1179 memory_budget_bytes,
1180 non_degree_bytes,
1181 num_terms,
1182 parallel_bisect_lanes,
1183 ),
1184 budget_limited,
1185 })
1186}
1187
1188#[derive(Clone, Copy, Debug, Default)]
1196pub struct BpBudget {
1197 pub min_partition_docs: Option<usize>,
1202 pub time_budget: Option<std::time::Duration>,
1206}
1207
1208impl BpBudget {
1209 pub fn full() -> Self {
1211 Self::default()
1212 }
1213}
1214
1215fn build_term_degrees(
1222 docs: &[u32],
1223 mid: usize,
1224 fwd: &ForwardIndex,
1225 workspaces: &mut [TermDegrees],
1226 cancellation: Option<&std::sync::atomic::AtomicBool>,
1227) {
1228 debug_assert!(!workspaces.is_empty());
1229 let build_range = |degrees: &mut TermDegrees, start: usize, chunk: &[u32]| {
1230 degrees.reset();
1231 for (offset, &doc) in chunk.iter().enumerate() {
1232 if offset.is_multiple_of(1024)
1233 && cancellation
1234 .is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Relaxed))
1235 {
1236 break;
1237 }
1238 let side = usize::from(start + offset >= mid);
1239 for &term in fwd.doc_terms(doc as usize) {
1240 degrees.entry_mut(term as usize)[side] += 1;
1241 }
1242 }
1243 };
1244
1245 #[cfg(feature = "native")]
1246 {
1247 let workers = workspaces
1248 .len()
1249 .min(docs.len().div_ceil(PARALLEL_BP_MIN_ENTITIES).max(1));
1250 if workers > 1 {
1251 let chunk_len = docs.len().div_ceil(workers);
1252 workspaces[..workers]
1253 .par_iter_mut()
1254 .enumerate()
1255 .for_each(|(worker, degrees)| {
1256 let start = worker * chunk_len;
1257 let end = (start + chunk_len).min(docs.len());
1258 build_range(degrees, start, &docs[start..end]);
1259 });
1260 let (degrees, partials) = workspaces[..workers]
1261 .split_first_mut()
1262 .expect("at least one BP degree workspace");
1263 for partial in partials {
1264 degrees.merge_from(partial);
1265 }
1266 return;
1267 }
1268 build_range(&mut workspaces[0], 0, docs);
1269 }
1270
1271 #[cfg(not(feature = "native"))]
1272 build_range(&mut workspaces[0], 0, docs);
1273}
1274
1275#[inline]
1281fn gain_order_key(gain: f32) -> u32 {
1282 let bits = gain.to_bits();
1283 if bits & 0x8000_0000 != 0 {
1284 !bits
1285 } else {
1286 bits ^ 0x8000_0000
1287 }
1288}
1289
1290fn select_gain_threshold(
1294 gains: &[f32],
1295 left_count: usize,
1296 allow_inner_parallelism: bool,
1297) -> (u32, usize) {
1298 #[cfg(not(feature = "native"))]
1299 let _ = allow_inner_parallelism;
1300
1301 debug_assert!(left_count > 0 && left_count <= gains.len());
1302 let mut rank_within_prefix = left_count - 1;
1303 let mut strictly_lower = 0usize;
1304 let mut prefix = 0u32;
1305 let mut prefix_mask = 0u32;
1306
1307 for shift in [24u32, 16, 8, 0] {
1308 let histogram = {
1309 #[cfg(feature = "native")]
1310 {
1311 if allow_inner_parallelism && gains.len() >= PARALLEL_BP_MIN_ENTITIES {
1312 gains
1313 .par_iter()
1314 .fold(
1315 || Box::new([0usize; 256]),
1316 |mut counts, &gain| {
1317 let key = gain_order_key(gain);
1318 if key & prefix_mask == prefix {
1319 counts[((key >> shift) & 0xff) as usize] += 1;
1320 }
1321 counts
1322 },
1323 )
1324 .reduce(
1325 || Box::new([0usize; 256]),
1326 |mut left, right| {
1327 for (dst, &count) in left.iter_mut().zip(right.iter()) {
1328 *dst += count;
1329 }
1330 left
1331 },
1332 )
1333 } else {
1334 let mut counts = [0usize; 256];
1335 for &gain in gains {
1336 let key = gain_order_key(gain);
1337 if key & prefix_mask == prefix {
1338 counts[((key >> shift) & 0xff) as usize] += 1;
1339 }
1340 }
1341 Box::new(counts)
1342 }
1343 }
1344 #[cfg(not(feature = "native"))]
1345 {
1346 let mut counts = [0usize; 256];
1347 for &gain in gains {
1348 let key = gain_order_key(gain);
1349 if key & prefix_mask == prefix {
1350 counts[((key >> shift) & 0xff) as usize] += 1;
1351 }
1352 }
1353 counts
1354 }
1355 };
1356
1357 let mut before_bucket = 0usize;
1358 let mut selected_bucket = None;
1359 for (bucket, count) in histogram.iter().copied().enumerate() {
1360 if rank_within_prefix < before_bucket + count {
1361 selected_bucket = Some(bucket as u32);
1362 rank_within_prefix -= before_bucket;
1363 strictly_lower += before_bucket;
1364 break;
1365 }
1366 before_bucket += count;
1367 }
1368 let selected_bucket = selected_bucket.expect("BP radix selection lost the requested rank");
1369 prefix |= selected_bucket << shift;
1370 prefix_mask |= 0xffu32 << shift;
1371 }
1372
1373 (prefix, strictly_lower)
1374}
1375
1376enum PartitionDegreeUpdate {
1377 Moves,
1380 Ranked,
1384 Threshold {
1387 threshold_key: u32,
1388 ties_left: usize,
1389 },
1390}
1391
1392struct PartitionOutcome {
1393 swap_count: usize,
1394 degree_update: PartitionDegreeUpdate,
1395}
1396
1397#[derive(Clone, Copy)]
1398struct PartitionChunk {
1399 start: usize,
1400 end: usize,
1401 strictly_lower: usize,
1402 equal: usize,
1403 ties_left: usize,
1404}
1405
1406#[inline]
1407fn select_left(key: u32, threshold_key: u32, equal_seen: &mut usize, ties_left: usize) -> bool {
1408 if key < threshold_key {
1409 true
1410 } else if key == threshold_key {
1411 let selected = *equal_seen < ties_left;
1412 *equal_seen += 1;
1413 selected
1414 } else {
1415 false
1416 }
1417}
1418
1419#[allow(clippy::too_many_arguments)]
1428fn partition_by_gain(
1429 docs: &[u32],
1430 gains: &[f32],
1431 mid: usize,
1432 fwd: &ForwardIndex,
1433 movement_workspaces: &mut [TermDegrees],
1434 output: &mut [u32],
1435 ranked_scratch: &mut [usize],
1436 allow_inner_parallelism: bool,
1437) -> PartitionOutcome {
1438 #[cfg(not(feature = "native"))]
1439 let _ = (fwd, &movement_workspaces);
1440
1441 #[cfg(feature = "native")]
1442 if allow_inner_parallelism
1443 && !movement_workspaces.is_empty()
1444 && docs.len() >= PARALLEL_BP_MIN_ENTITIES
1445 {
1446 let (threshold_key, strictly_lower) =
1447 select_gain_threshold(gains, mid, allow_inner_parallelism);
1448 let ties_left = mid - strictly_lower;
1449
1450 let chunk_count = movement_workspaces
1453 .len()
1454 .min(docs.len().div_ceil(PARALLEL_BP_MIN_ENTITIES).max(1));
1455 let chunk_len = docs.len().div_ceil(chunk_count);
1456 let mut chunks: Vec<PartitionChunk> = gains
1457 .par_chunks(chunk_len)
1458 .enumerate()
1459 .map(|(chunk_id, chunk)| {
1460 let mut lower = 0usize;
1461 let mut equal = 0usize;
1462 for &gain in chunk {
1463 match gain_order_key(gain).cmp(&threshold_key) {
1464 std::cmp::Ordering::Less => lower += 1,
1465 std::cmp::Ordering::Equal => equal += 1,
1466 std::cmp::Ordering::Greater => {}
1467 }
1468 }
1469 let start = chunk_id * chunk_len;
1470 PartitionChunk {
1471 start,
1472 end: start + chunk.len(),
1473 strictly_lower: lower,
1474 equal,
1475 ties_left: 0,
1476 }
1477 })
1478 .collect();
1479
1480 let mut remaining_ties = ties_left;
1481 for chunk in &mut chunks {
1482 chunk.ties_left = remaining_ties.min(chunk.equal);
1483 remaining_ties -= chunk.ties_left;
1484 }
1485 debug_assert_eq!(remaining_ties, 0);
1486
1487 let (mut left_rest, mut right_rest) = output.split_at_mut(mid);
1488 let mut jobs = Vec::with_capacity(chunks.len());
1489 for chunk in chunks {
1490 let left_len = chunk.strictly_lower + chunk.ties_left;
1491 let right_len = chunk.end - chunk.start - left_len;
1492 let (left_out, next_left) = left_rest.split_at_mut(left_len);
1493 let (right_out, next_right) = right_rest.split_at_mut(right_len);
1494 jobs.push((
1495 chunk.start,
1496 &docs[chunk.start..chunk.end],
1497 &gains[chunk.start..chunk.end],
1498 chunk.ties_left,
1499 left_out,
1500 right_out,
1501 ));
1502 left_rest = next_left;
1503 right_rest = next_right;
1504 }
1505 debug_assert!(left_rest.is_empty() && right_rest.is_empty());
1506
1507 let swap_count = movement_workspaces[..chunk_count]
1508 .par_iter_mut()
1509 .zip(jobs.into_par_iter())
1510 .map(
1511 |(moves, (start, docs, gains, ties_for_chunk, left_out, right_out))| {
1512 moves.reset();
1513 let mut equal_seen = 0usize;
1514 let mut left_cursor = 0usize;
1515 let mut right_cursor = 0usize;
1516 let mut swaps = 0usize;
1517
1518 for (offset, (&doc, &gain)) in docs.iter().zip(gains).enumerate() {
1519 let key = gain_order_key(gain);
1520 let now_left =
1521 select_left(key, threshold_key, &mut equal_seen, ties_for_chunk);
1522 if now_left {
1523 left_out[left_cursor] = doc;
1524 left_cursor += 1;
1525 } else {
1526 right_out[right_cursor] = doc;
1527 right_cursor += 1;
1528 }
1529
1530 let was_left = start + offset < mid;
1531 if was_left != now_left {
1532 swaps += 1;
1533 let direction = usize::from(was_left);
1535 for &term in fwd.doc_terms(doc as usize) {
1536 moves.entry_mut(term as usize)[direction] += 1;
1537 }
1538 }
1539 }
1540 debug_assert_eq!(left_cursor, left_out.len());
1541 debug_assert_eq!(right_cursor, right_out.len());
1542 swaps
1543 },
1544 )
1545 .sum();
1546
1547 let (moves, partials) = movement_workspaces[..chunk_count]
1548 .split_first_mut()
1549 .expect("parallel BP partition must use at least one movement workspace");
1550 for partial in partials {
1551 moves.merge_from(partial);
1552 }
1553
1554 return PartitionOutcome {
1555 swap_count,
1556 degree_update: PartitionDegreeUpdate::Moves,
1557 };
1558 }
1559
1560 if docs.len() < PARALLEL_BP_MIN_ENTITIES {
1561 debug_assert_eq!(ranked_scratch.len(), docs.len());
1562 for (index, rank) in ranked_scratch.iter_mut().enumerate() {
1563 *rank = index;
1564 }
1565 ranked_scratch.select_nth_unstable_by(mid, |&left, &right| {
1566 gains[left]
1567 .total_cmp(&gains[right])
1568 .then_with(|| left.cmp(&right))
1569 });
1570
1571 let mut swaps = 0usize;
1572 for (rank, &old_index) in ranked_scratch.iter().enumerate() {
1573 output[rank] = docs[old_index];
1574 swaps += usize::from((old_index < mid) != (rank < mid));
1575 }
1576 return PartitionOutcome {
1577 swap_count: swaps,
1578 degree_update: PartitionDegreeUpdate::Ranked,
1579 };
1580 }
1581
1582 let (threshold_key, strictly_lower) =
1583 select_gain_threshold(gains, mid, allow_inner_parallelism);
1584 let ties_left = mid - strictly_lower;
1585 let mut equal_seen = 0usize;
1586 let mut left_cursor = 0usize;
1587 let mut right_cursor = mid;
1588 let mut swaps = 0usize;
1589 for (idx, (&doc, &gain)) in docs.iter().zip(gains).enumerate() {
1590 let key = gain_order_key(gain);
1591 let now_left = select_left(key, threshold_key, &mut equal_seen, ties_left);
1592 if now_left {
1593 output[left_cursor] = doc;
1594 left_cursor += 1;
1595 } else {
1596 output[right_cursor] = doc;
1597 right_cursor += 1;
1598 }
1599 swaps += usize::from((idx < mid) != now_left);
1600 }
1601 debug_assert_eq!(left_cursor, mid);
1602 debug_assert_eq!(right_cursor, docs.len());
1603
1604 PartitionOutcome {
1605 swap_count: swaps,
1606 degree_update: PartitionDegreeUpdate::Threshold {
1607 threshold_key,
1608 ties_left,
1609 },
1610 }
1611}
1612
1613fn update_degrees_for_threshold_partition(
1615 docs: &[u32],
1616 gains: &[f32],
1617 mid: usize,
1618 threshold_key: u32,
1619 ties_left: usize,
1620 fwd: &ForwardIndex,
1621 degrees: &mut TermDegrees,
1622) {
1623 let mut equal_seen = 0usize;
1624 for (idx, (&doc, &gain)) in docs.iter().zip(gains).enumerate() {
1625 let key = gain_order_key(gain);
1626 let now_left = select_left(key, threshold_key, &mut equal_seen, ties_left);
1627 let was_left = idx < mid;
1628 if was_left == now_left {
1629 continue;
1630 }
1631 let left_delta = if was_left { -1i64 } else { 1i64 };
1632 for &term in fwd.doc_terms(doc as usize) {
1633 let degree = degrees.entry_mut(term as usize);
1634 let new_left = degree[0] as i64 + left_delta;
1635 let new_right = degree[1] as i64 - left_delta;
1636 debug_assert!(new_left >= 0 && new_right >= 0);
1637 degree[0] = new_left as u32;
1638 degree[1] = new_right as u32;
1639 }
1640 }
1641}
1642
1643fn update_degrees_for_ranked_partition(
1646 docs: &[u32],
1647 ranked: &[usize],
1648 mid: usize,
1649 fwd: &ForwardIndex,
1650 degrees: &mut TermDegrees,
1651) {
1652 for (rank, &old_index) in ranked.iter().enumerate() {
1653 let was_left = old_index < mid;
1654 let now_left = rank < mid;
1655 if was_left == now_left {
1656 continue;
1657 }
1658 let left_delta = if was_left { -1i64 } else { 1i64 };
1659 for &term in fwd.doc_terms(docs[old_index] as usize) {
1660 let degree = degrees.entry_mut(term as usize);
1661 let new_left = degree[0] as i64 + left_delta;
1662 let new_right = degree[1] as i64 - left_delta;
1663 debug_assert!(new_left >= 0 && new_right >= 0);
1664 degree[0] = new_left as u32;
1665 degree[1] = new_right as u32;
1666 }
1667 }
1668}
1669
1670#[derive(Clone, Copy)]
1671pub(crate) struct BpProgressLabel<'a> {
1672 pub index: &'a str,
1673 pub field: &'a str,
1674 pub entity_kind: &'static str,
1675}
1676
1677#[cfg(test)]
1678impl BpProgressLabel<'static> {
1679 fn anonymous() -> Self {
1680 Self {
1681 index: "unknown",
1682 field: "unknown",
1683 entity_kind: "entities",
1684 }
1685 }
1686}
1687
1688#[cfg(feature = "native")]
1689struct BpProgress<'a> {
1690 label: BpProgressLabel<'a>,
1691 start: std::time::Instant,
1692 total_entities: usize,
1693 total_postings: u64,
1694 expected_depth: usize,
1695 next_log_ms: std::sync::atomic::AtomicU64,
1696 active_partitions: std::sync::atomic::AtomicU64,
1697 partitions_started: std::sync::atomic::AtomicU64,
1698 partitions_completed: std::sync::atomic::AtomicU64,
1699 iterations: std::sync::atomic::AtomicU64,
1700 entity_passes: std::sync::atomic::AtomicU64,
1701 swaps: std::sync::atomic::AtomicU64,
1702 deepest_level: std::sync::atomic::AtomicU64,
1703 objective_stops: std::sync::atomic::AtomicU64,
1704 last_objective_delta_bits: std::sync::atomic::AtomicU64,
1705 last_relative_delta_bits: std::sync::atomic::AtomicU64,
1706 active_metric_released: std::sync::atomic::AtomicBool,
1707}
1708
1709#[cfg(feature = "native")]
1710impl<'a> BpProgress<'a> {
1711 fn new(
1712 label: BpProgressLabel<'a>,
1713 total_entities: usize,
1714 total_postings: u64,
1715 expected_depth: usize,
1716 degree_lanes: usize,
1717 ) -> Self {
1718 log::info!(
1719 "[reorder][bp] started: index={} field={} entity_kind={} scheduler=level_synchronized degree_lanes={} entities={} postings={} expected_depth={} objective_stall_threshold={:.1e}x{} min_objective_iterations={}",
1720 label.index,
1721 label.field,
1722 label.entity_kind,
1723 degree_lanes,
1724 total_entities,
1725 total_postings,
1726 expected_depth,
1727 MIN_RELATIVE_OBJECTIVE_IMPROVEMENT,
1728 OBJECTIVE_STALL_ITERATIONS,
1729 MIN_OBJECTIVE_ITERATIONS,
1730 );
1731 crate::observe::reorder_bp_started(label.index, label.field, label.entity_kind);
1732 Self {
1733 label,
1734 start: std::time::Instant::now(),
1735 total_entities,
1736 total_postings,
1737 expected_depth,
1738 next_log_ms: std::sync::atomic::AtomicU64::new(30_000),
1739 active_partitions: std::sync::atomic::AtomicU64::new(0),
1740 partitions_started: std::sync::atomic::AtomicU64::new(0),
1741 partitions_completed: std::sync::atomic::AtomicU64::new(0),
1742 iterations: std::sync::atomic::AtomicU64::new(0),
1743 entity_passes: std::sync::atomic::AtomicU64::new(0),
1744 swaps: std::sync::atomic::AtomicU64::new(0),
1745 deepest_level: std::sync::atomic::AtomicU64::new(0),
1746 objective_stops: std::sync::atomic::AtomicU64::new(0),
1747 last_objective_delta_bits: std::sync::atomic::AtomicU64::new(0f64.to_bits()),
1748 last_relative_delta_bits: std::sync::atomic::AtomicU64::new(0f64.to_bits()),
1749 active_metric_released: std::sync::atomic::AtomicBool::new(false),
1750 }
1751 }
1752
1753 fn partition_started(&self, level: usize) {
1754 self.active_partitions
1755 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1756 self.partitions_started
1757 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1758 self.deepest_level
1759 .fetch_max(level as u64, std::sync::atomic::Ordering::Relaxed);
1760 }
1761
1762 fn partition_finished(&self) {
1763 self.partitions_completed
1764 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1765 self.active_partitions
1766 .fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
1767 }
1768
1769 fn iteration(&self, entities: usize, swaps: usize, objective_delta: f64, relative_delta: f64) {
1770 self.iterations
1771 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1772 self.entity_passes
1773 .fetch_add(entities as u64, std::sync::atomic::Ordering::Relaxed);
1774 self.swaps
1775 .fetch_add(swaps as u64, std::sync::atomic::Ordering::Relaxed);
1776 self.last_objective_delta_bits.store(
1777 objective_delta.to_bits(),
1778 std::sync::atomic::Ordering::Relaxed,
1779 );
1780 self.last_relative_delta_bits.store(
1781 relative_delta.to_bits(),
1782 std::sync::atomic::Ordering::Relaxed,
1783 );
1784 self.maybe_log();
1785 }
1786
1787 fn objective_stop(&self) {
1788 self.objective_stops
1789 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1790 }
1791
1792 fn maybe_log(&self) {
1793 let elapsed_ms = self.start.elapsed().as_millis().min(u64::MAX as u128) as u64;
1794 let next = self.next_log_ms.load(std::sync::atomic::Ordering::Relaxed);
1795 if elapsed_ms < next
1796 || self
1797 .next_log_ms
1798 .compare_exchange(
1799 next,
1800 elapsed_ms.saturating_add(30_000),
1801 std::sync::atomic::Ordering::Relaxed,
1802 std::sync::atomic::Ordering::Relaxed,
1803 )
1804 .is_err()
1805 {
1806 return;
1807 }
1808
1809 let active = self
1810 .active_partitions
1811 .load(std::sync::atomic::Ordering::Relaxed);
1812 let started = self
1813 .partitions_started
1814 .load(std::sync::atomic::Ordering::Relaxed);
1815 let completed = self
1816 .partitions_completed
1817 .load(std::sync::atomic::Ordering::Relaxed);
1818 let iterations = self.iterations.load(std::sync::atomic::Ordering::Relaxed);
1819 let entity_passes = self
1820 .entity_passes
1821 .load(std::sync::atomic::Ordering::Relaxed);
1822 let swaps = self.swaps.load(std::sync::atomic::Ordering::Relaxed);
1823 let deepest = self
1824 .deepest_level
1825 .load(std::sync::atomic::Ordering::Relaxed);
1826 let objective_delta = f64::from_bits(
1827 self.last_objective_delta_bits
1828 .load(std::sync::atomic::Ordering::Relaxed),
1829 );
1830 let relative_delta = f64::from_bits(
1831 self.last_relative_delta_bits
1832 .load(std::sync::atomic::Ordering::Relaxed),
1833 );
1834 log::info!(
1835 "[reorder][bp] progress: index={} field={} entity_kind={} elapsed={:.1}s depth={}/{} partitions={}/{} active={} iterations={} entity_passes={} swaps={} last_objective_delta={:.3} relative={:.3e}",
1836 self.label.index,
1837 self.label.field,
1838 self.label.entity_kind,
1839 self.start.elapsed().as_secs_f64(),
1840 deepest,
1841 self.expected_depth,
1842 completed,
1843 started,
1844 active,
1845 iterations,
1846 entity_passes,
1847 swaps,
1848 objective_delta,
1849 relative_delta,
1850 );
1851 }
1852
1853 fn finish(
1854 &self,
1855 converged: bool,
1856 memory_limited: bool,
1857 deadline_exhausted: bool,
1858 cancelled: bool,
1859 ) {
1860 let elapsed = self.start.elapsed().as_secs_f64();
1861 let partitions = self
1862 .partitions_completed
1863 .load(std::sync::atomic::Ordering::Relaxed);
1864 let iterations = self.iterations.load(std::sync::atomic::Ordering::Relaxed);
1865 let entity_passes = self
1866 .entity_passes
1867 .load(std::sync::atomic::Ordering::Relaxed);
1868 let swaps = self.swaps.load(std::sync::atomic::Ordering::Relaxed);
1869 let deepest = self
1870 .deepest_level
1871 .load(std::sync::atomic::Ordering::Relaxed);
1872 let objective_stops = self
1873 .objective_stops
1874 .load(std::sync::atomic::Ordering::Relaxed);
1875 let stop_reason = if cancelled {
1876 "shutdown"
1877 } else if memory_limited {
1878 "memory_budget"
1879 } else if deadline_exhausted {
1880 "time_budget"
1881 } else if objective_stops > 0 {
1882 "objective"
1883 } else {
1884 "complete"
1885 };
1886 log::info!(
1887 "[reorder][bp] completed: index={} field={} entity_kind={} entities={} postings={} elapsed={:.1}s depth={}/{} partitions={} iterations={} entity_passes={} swaps={} objective_stops={} converged={} stop_reason={}",
1888 self.label.index,
1889 self.label.field,
1890 self.label.entity_kind,
1891 self.total_entities,
1892 self.total_postings,
1893 elapsed,
1894 deepest,
1895 self.expected_depth,
1896 partitions,
1897 iterations,
1898 entity_passes,
1899 swaps,
1900 objective_stops,
1901 converged,
1902 stop_reason,
1903 );
1904 crate::observe::reorder_bp_pass(
1905 self.label.index,
1906 self.label.field,
1907 self.label.entity_kind,
1908 stop_reason,
1909 elapsed,
1910 self.total_entities,
1911 self.total_postings,
1912 partitions,
1913 iterations,
1914 entity_passes,
1915 swaps,
1916 converged,
1917 );
1918 self.release_active_metric();
1919 }
1920
1921 fn release_active_metric(&self) {
1922 if !self
1923 .active_metric_released
1924 .swap(true, std::sync::atomic::Ordering::AcqRel)
1925 {
1926 crate::observe::reorder_bp_finished(
1927 self.label.index,
1928 self.label.field,
1929 self.label.entity_kind,
1930 );
1931 }
1932 }
1933}
1934
1935#[cfg(feature = "native")]
1936impl Drop for BpProgress<'_> {
1937 fn drop(&mut self) {
1938 self.release_active_metric();
1939 }
1940}
1941
1942#[cfg(not(feature = "native"))]
1943struct BpProgress<'a>(std::marker::PhantomData<&'a ()>);
1944
1945#[cfg(not(feature = "native"))]
1946impl BpProgress<'_> {
1947 fn new(_: BpProgressLabel<'_>, _: usize, _: u64, _: usize, _: usize) -> Self {
1948 Self(std::marker::PhantomData)
1949 }
1950 fn partition_started(&self, _: usize) {}
1951 fn partition_finished(&self) {}
1952 fn iteration(&self, _: usize, _: usize, _: f64, _: f64) {}
1953 fn objective_stop(&self) {}
1954 fn finish(&self, _: bool, _: bool, _: bool, _: bool) {}
1955}
1956
1957#[cfg(test)]
1968pub(crate) fn graph_bisection(
1969 fwd: &ForwardIndex,
1970 min_partition_size: usize,
1971 max_iters: usize,
1972 budget: BpBudget,
1973) -> (Vec<u32>, bool) {
1974 graph_bisection_with_progress(
1975 fwd,
1976 min_partition_size,
1977 max_iters,
1978 budget,
1979 None,
1980 BpProgressLabel::anonymous(),
1981 )
1982}
1983
1984pub(crate) fn graph_bisection_with_progress(
1985 fwd: &ForwardIndex,
1986 min_partition_size: usize,
1987 max_iters: usize,
1988 budget: BpBudget,
1989 cancellation: Option<&std::sync::atomic::AtomicBool>,
1990 progress_label: BpProgressLabel<'_>,
1991) -> (Vec<u32>, bool) {
1992 let n = fwd.num_docs();
1993 if n == 0 {
1994 return (Vec::new(), !fwd.budget_limited);
1995 }
1996
1997 let effective_min_partition = budget
1998 .min_partition_docs
1999 .unwrap_or(0)
2000 .max(min_partition_size)
2001 .max(1);
2006
2007 let mut docs: Vec<u32> = (0..n as u32).collect();
2008 let depth = if effective_min_partition > 0 {
2009 ((n as f64) / (effective_min_partition as f64))
2010 .log2()
2011 .ceil() as usize
2012 } else {
2013 0
2014 };
2015 #[cfg(feature = "native")]
2016 let degree_lanes = fwd.parallel_bisect_lanes.max(1);
2017 #[cfg(not(feature = "native"))]
2018 let degree_lanes = 1usize;
2019 let log_table = build_log_table(LOG_TABLE_SIZE);
2020 let progress = BpProgress::new(progress_label, n, fwd.total_postings(), depth, degree_lanes);
2021
2022 log::debug!(
2023 "BP graph_bisection: n={}, min_partition={}, max_iters={}, depth=~{}, time_budget={:?}, gain_cache={}",
2024 n,
2025 effective_min_partition,
2026 max_iters,
2027 depth,
2028 budget.time_budget,
2029 fwd.cache_gains,
2030 );
2031
2032 #[cfg(feature = "native")]
2033 let deadline = budget.time_budget.map(|duration| {
2034 let now = std::time::Instant::now();
2035 now.checked_add(duration).unwrap_or(now)
2036 });
2037 #[cfg(not(feature = "native"))]
2038 let deadline: Option<()> = None;
2039
2040 let exhausted = std::sync::atomic::AtomicBool::new(false);
2041 let context = BisectContext {
2042 fwd,
2043 min_partition_size: effective_min_partition,
2044 max_iters,
2045 log_table: &log_table,
2046 #[cfg(feature = "native")]
2047 deadline,
2048 #[cfg(not(feature = "native"))]
2049 deadline,
2050 exhausted: &exhausted,
2051 cancellation,
2052 progress: &progress,
2053 };
2054 let immediately_exhausted = {
2055 #[cfg(feature = "native")]
2056 {
2057 deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline)
2058 }
2059 #[cfg(not(feature = "native"))]
2060 {
2061 false
2062 }
2063 };
2064 let initially_cancelled =
2065 cancellation.is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Acquire));
2066 if immediately_exhausted || initially_cancelled {
2067 exhausted.store(true, std::sync::atomic::Ordering::Relaxed);
2068 } else if n > effective_min_partition {
2069 let mut gains = vec![0.0f32; n];
2073 let mut partitioned = vec![0u32; n];
2074 let mut ranked = vec![0usize; n];
2075 let mut degree_workspaces: Vec<TermDegrees> = (0..degree_lanes)
2076 .map(|_| {
2077 let mut lane = TermDegrees::new(fwd.num_terms);
2078 if fwd.cache_gains {
2079 lane.gain_cache = vec![[0.0; 2]; fwd.num_terms];
2080 }
2081 lane
2082 })
2083 .collect();
2084 bisect_level_synchronized(
2085 &mut docs,
2086 &mut gains,
2087 &mut partitioned,
2088 &mut ranked,
2089 &mut degree_workspaces,
2090 &context,
2091 );
2092 }
2093
2094 let stopped_early = exhausted.load(std::sync::atomic::Ordering::Relaxed);
2095 let cancelled =
2096 cancellation.is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Acquire));
2097 let deadline_exhausted = stopped_early && !cancelled;
2098 let converged = !fwd.budget_limited && !stopped_early && !cancelled;
2099 progress.finish(converged, fwd.budget_limited, deadline_exhausted, cancelled);
2100 if !converged {
2101 log::info!(
2102 "BP graph_bisection: pass incomplete at n={} (time={:?}, memory_limited={}, cancelled={}) — emitting partial (still valid) permutation",
2103 n,
2104 budget.time_budget,
2105 fwd.budget_limited,
2106 cancelled,
2107 );
2108 }
2109 (docs, converged)
2110}
2111
2112struct BisectContext<'a> {
2118 fwd: &'a ForwardIndex,
2119 min_partition_size: usize,
2120 max_iters: usize,
2121 log_table: &'a [f32],
2122 #[cfg(feature = "native")]
2123 deadline: Option<std::time::Instant>,
2124 #[cfg(not(feature = "native"))]
2125 deadline: Option<()>,
2126 exhausted: &'a std::sync::atomic::AtomicBool,
2127 cancellation: Option<&'a std::sync::atomic::AtomicBool>,
2128 progress: &'a BpProgress<'a>,
2129}
2130
2131fn partition_range(total: usize, level: usize, partition_id: usize) -> std::ops::Range<usize> {
2137 debug_assert!(level < usize::BITS as usize);
2138 debug_assert!(partition_id < (1usize << level));
2139 let mut start = 0usize;
2140 let mut len = total;
2141 for bit in (0..level).rev() {
2142 let left_len = len / 2;
2143 if partition_id & (1usize << bit) == 0 {
2144 len = left_len;
2145 } else {
2146 start += left_len;
2147 len -= left_len;
2148 }
2149 }
2150 start..start + len
2151}
2152
2153fn small_active_partition_set(
2157 total: usize,
2158 level: usize,
2159 min_partition_size: usize,
2160 limit: usize,
2161) -> Option<Vec<usize>> {
2162 let partition_count = 1usize.checked_shl(level as u32)?;
2163 let mut active = Vec::with_capacity(partition_count.min(limit));
2164 for partition_id in 0..partition_count {
2165 if partition_range(total, level, partition_id).len() <= min_partition_size {
2166 continue;
2167 }
2168 active.push(partition_id);
2169 if active.len() > limit {
2170 return None;
2171 }
2172 }
2173 Some(active)
2174}
2175
2176#[cfg(feature = "native")]
2183#[derive(Clone, Copy)]
2184struct LevelBuffers {
2185 docs: *mut u32,
2186 gains: *mut f32,
2187 partitioned: *mut u32,
2188 ranked: *mut usize,
2189 len: usize,
2190}
2191
2192#[cfg(feature = "native")]
2193unsafe impl Send for LevelBuffers {}
2194#[cfg(feature = "native")]
2195unsafe impl Sync for LevelBuffers {}
2196
2197#[cfg(feature = "native")]
2198impl LevelBuffers {
2199 fn new(
2200 docs: &mut [u32],
2201 gains: &mut [f32],
2202 partitioned: &mut [u32],
2203 ranked: &mut [usize],
2204 ) -> Self {
2205 debug_assert_eq!(docs.len(), gains.len());
2206 debug_assert_eq!(docs.len(), partitioned.len());
2207 debug_assert_eq!(docs.len(), ranked.len());
2208 Self {
2209 docs: docs.as_mut_ptr(),
2210 gains: gains.as_mut_ptr(),
2211 partitioned: partitioned.as_mut_ptr(),
2212 ranked: ranked.as_mut_ptr(),
2213 len: docs.len(),
2214 }
2215 }
2216
2217 unsafe fn with_slices<R>(
2222 &self,
2223 range: std::ops::Range<usize>,
2224 use_slices: impl for<'a> FnOnce(
2225 &'a mut [u32],
2226 &'a mut [f32],
2227 &'a mut [u32],
2228 &'a mut [usize],
2229 ) -> R,
2230 ) -> R {
2231 debug_assert!(range.start <= range.end && range.end <= self.len);
2232 let len = range.len();
2233 unsafe {
2236 use_slices(
2237 std::slice::from_raw_parts_mut(self.docs.add(range.start), len),
2238 std::slice::from_raw_parts_mut(self.gains.add(range.start), len),
2239 std::slice::from_raw_parts_mut(self.partitioned.add(range.start), len),
2240 std::slice::from_raw_parts_mut(self.ranked.add(range.start), len),
2241 )
2242 }
2243 }
2244}
2245
2246fn bisect_level_synchronized(
2251 docs: &mut [u32],
2252 gains: &mut [f32],
2253 partitioned: &mut [u32],
2254 ranked_scratch: &mut [usize],
2255 degree_workspaces: &mut [TermDegrees],
2256 context: &BisectContext<'_>,
2257) {
2258 debug_assert_eq!(docs.len(), gains.len());
2259 debug_assert_eq!(docs.len(), partitioned.len());
2260 debug_assert_eq!(docs.len(), ranked_scratch.len());
2261 debug_assert!(!degree_workspaces.is_empty());
2262
2263 #[cfg(feature = "native")]
2264 {
2265 let buffers = LevelBuffers::new(docs, gains, partitioned, ranked_scratch);
2266 let mut level = 0usize;
2267 while let Some(partition_count) = 1usize.checked_shl(level as u32) {
2268 let small_set = small_active_partition_set(
2269 docs.len(),
2270 level,
2271 context.min_partition_size,
2272 degree_workspaces.len(),
2273 );
2274
2275 match small_set {
2276 Some(active) if active.is_empty() => break,
2277 Some(active) => {
2278 let active_count = active.len();
2282 let allow_inner_parallelism =
2283 active_count < rayon::current_num_threads().max(1);
2284 let mut rest: &mut [TermDegrees] = &mut *degree_workspaces;
2285 let mut groups = Vec::with_capacity(active_count);
2286 for remaining_groups in (1..=active_count).rev() {
2287 let group_len = rest.len().div_ceil(remaining_groups);
2288 let (group, tail) = rest.split_at_mut(group_len);
2289 groups.push(group);
2290 rest = tail;
2291 }
2292 debug_assert!(rest.is_empty());
2293 groups.into_par_iter().zip(active.into_par_iter()).for_each(
2294 |(workspaces, partition_id)| {
2295 let range = partition_range(docs.len(), level, partition_id);
2296 unsafe {
2300 buffers.with_slices(
2301 range,
2302 |part_docs, part_gains, part_output, part_ranked| {
2303 bisect_partition(
2304 part_docs,
2305 part_gains,
2306 part_output,
2307 part_ranked,
2308 workspaces,
2309 level,
2310 allow_inner_parallelism,
2311 context,
2312 );
2313 },
2314 );
2315 }
2316 },
2317 );
2318 }
2319 None => {
2320 let next = std::sync::atomic::AtomicUsize::new(0);
2326 let allow_inner_parallelism =
2327 degree_workspaces.len() < rayon::current_num_threads().max(1);
2328 degree_workspaces.par_iter_mut().for_each(|workspace| {
2329 loop {
2330 let partition_id =
2331 next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2332 if partition_id >= partition_count {
2333 break;
2334 }
2335 let range = partition_range(docs.len(), level, partition_id);
2336 if range.len() <= context.min_partition_size {
2337 continue;
2338 }
2339 unsafe {
2343 buffers.with_slices(
2344 range,
2345 |part_docs, part_gains, part_output, part_ranked| {
2346 bisect_partition(
2347 part_docs,
2348 part_gains,
2349 part_output,
2350 part_ranked,
2351 std::slice::from_mut(workspace),
2352 level,
2353 allow_inner_parallelism,
2354 context,
2355 );
2356 },
2357 );
2358 }
2359 }
2360 });
2361 }
2362 }
2363
2364 if context.exhausted.load(std::sync::atomic::Ordering::Relaxed) {
2365 break;
2366 }
2367 level += 1;
2368 }
2369 }
2370
2371 #[cfg(not(feature = "native"))]
2372 {
2373 let mut level = 0usize;
2374 while let Some(partition_count) = 1usize.checked_shl(level as u32) {
2375 let mut active = false;
2376 for partition_id in 0..partition_count {
2377 let range = partition_range(docs.len(), level, partition_id);
2378 if range.len() <= context.min_partition_size {
2379 continue;
2380 }
2381 active = true;
2382 bisect_partition(
2383 &mut docs[range.clone()],
2384 &mut gains[range.clone()],
2385 &mut partitioned[range.clone()],
2386 &mut ranked_scratch[range],
2387 degree_workspaces,
2388 level,
2389 false,
2390 context,
2391 );
2392 }
2393 if !active || context.exhausted.load(std::sync::atomic::Ordering::Relaxed) {
2394 break;
2395 }
2396 level += 1;
2397 }
2398 }
2399}
2400
2401#[allow(clippy::too_many_arguments)]
2407fn bisect_partition(
2408 docs: &mut [u32],
2409 gains: &mut [f32],
2410 partitioned: &mut [u32],
2411 ranked_scratch: &mut [usize],
2412 degree_workspaces: &mut [TermDegrees],
2413 level: usize,
2414 allow_inner_parallelism: bool,
2415 context: &BisectContext<'_>,
2416) {
2417 let n = docs.len();
2418 debug_assert_eq!(gains.len(), n);
2419 debug_assert_eq!(partitioned.len(), n);
2420 debug_assert_eq!(ranked_scratch.len(), n);
2421 debug_assert!(!degree_workspaces.is_empty());
2422 if n <= context.min_partition_size {
2423 return;
2424 }
2425 if context.exhausted.load(std::sync::atomic::Ordering::Relaxed)
2427 || context
2428 .cancellation
2429 .is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Acquire))
2430 {
2431 context
2432 .exhausted
2433 .store(true, std::sync::atomic::Ordering::Relaxed);
2434 return;
2435 }
2436 #[cfg(feature = "native")]
2437 if let Some(dl) = context.deadline
2438 && std::time::Instant::now() >= dl
2439 {
2440 context
2441 .exhausted
2442 .store(true, std::sync::atomic::Ordering::Relaxed);
2443 return;
2444 }
2445 #[cfg(not(feature = "native"))]
2446 let _ = context.deadline;
2447
2448 context.progress.partition_started(level);
2449 let mid = n / 2;
2450
2451 let effective_iters = if n > 100_000 {
2455 context.max_iters.min(12)
2456 } else {
2457 context.max_iters
2458 };
2459
2460 build_term_degrees(
2465 docs,
2466 mid,
2467 context.fwd,
2468 degree_workspaces,
2469 context.cancellation,
2470 );
2471 if context
2472 .cancellation
2473 .is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Acquire))
2474 {
2475 context
2476 .exhausted
2477 .store(true, std::sync::atomic::Ordering::Relaxed);
2478 context.progress.partition_finished();
2479 return;
2480 }
2481 let track_objective = n >= PARALLEL_BP_MIN_ENTITIES;
2486 if track_objective {
2487 degree_workspaces[0].sort_touched_words();
2491 }
2492 let mut previous_objective = if track_objective {
2493 degree_workspaces[0].bisection_objective(mid, n - mid, context.log_table)
2494 } else {
2495 0.0
2496 };
2497 let mut best_objective = previous_objective;
2498 let mut objective_stalls = 0usize;
2499
2500 for iter in 0..effective_iters {
2501 if context
2503 .cancellation
2504 .is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Acquire))
2505 {
2506 context
2507 .exhausted
2508 .store(true, std::sync::atomic::Ordering::Relaxed);
2509 break;
2510 }
2511 #[cfg(feature = "native")]
2512 if let Some(dl) = context.deadline
2513 && std::time::Instant::now() >= dl
2514 {
2515 context
2516 .exhausted
2517 .store(true, std::sync::atomic::Ordering::Relaxed);
2518 break;
2519 }
2520 compute_gains(
2523 docs,
2524 context.fwd,
2525 mid,
2526 &mut degree_workspaces[0],
2527 context.log_table,
2528 gains,
2529 allow_inner_parallelism,
2530 );
2531 if context
2532 .cancellation
2533 .is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Acquire))
2534 {
2535 context
2536 .exhausted
2537 .store(true, std::sync::atomic::Ordering::Relaxed);
2538 break;
2539 }
2540
2541 let partition = partition_by_gain(
2546 docs,
2547 gains,
2548 mid,
2549 context.fwd,
2550 &mut degree_workspaces[1..],
2551 partitioned,
2552 ranked_scratch,
2553 allow_inner_parallelism,
2554 );
2555
2556 if partition.swap_count == 0 {
2557 context.progress.iteration(n, 0, 0.0, 0.0);
2558 docs.copy_from_slice(partitioned);
2562 break;
2563 }
2564
2565 match &partition.degree_update {
2566 PartitionDegreeUpdate::Moves => {
2567 let (degrees, movement_workspaces) = degree_workspaces
2568 .split_first_mut()
2569 .expect("BP always has one degree workspace");
2570 movement_workspaces
2571 .first()
2572 .expect("parallel BP movement update requires a spare workspace")
2573 .apply_moves_to(degrees);
2574 }
2575 PartitionDegreeUpdate::Ranked => update_degrees_for_ranked_partition(
2576 docs,
2577 ranked_scratch,
2578 mid,
2579 context.fwd,
2580 &mut degree_workspaces[0],
2581 ),
2582 PartitionDegreeUpdate::Threshold {
2583 threshold_key,
2584 ties_left,
2585 } => update_degrees_for_threshold_partition(
2586 docs,
2587 gains,
2588 mid,
2589 *threshold_key,
2590 *ties_left,
2591 context.fwd,
2592 &mut degree_workspaces[0],
2593 ),
2594 }
2595
2596 let (new_objective, objective_improvement, relative_improvement) = if track_objective {
2597 let new_objective =
2598 degree_workspaces[0].bisection_objective(mid, n - mid, context.log_table);
2599 let objective_improvement = new_objective - previous_objective;
2600 let relative_improvement = objective_improvement / previous_objective.abs().max(1.0);
2601 (new_objective, objective_improvement, relative_improvement)
2602 } else {
2603 (0.0, 0.0, 0.0)
2604 };
2605 context.progress.iteration(
2606 n,
2607 partition.swap_count,
2608 objective_improvement,
2609 relative_improvement,
2610 );
2611
2612 docs.copy_from_slice(partitioned);
2619 if track_objective {
2620 previous_objective = new_objective;
2621 let relative_best_improvement =
2622 (new_objective - best_objective) / best_objective.abs().max(1.0);
2623 if relative_best_improvement >= MIN_RELATIVE_OBJECTIVE_IMPROVEMENT {
2624 best_objective = new_objective;
2625 objective_stalls = 0;
2626 } else if iter + 1 >= MIN_OBJECTIVE_ITERATIONS {
2627 objective_stalls += 1;
2628 }
2629 if objective_stalls >= OBJECTIVE_STALL_ITERATIONS {
2630 context.progress.objective_stop();
2631 break;
2632 }
2633 }
2634
2635 if iter > 2 && partition.swap_count < n / 200 {
2637 break;
2638 }
2639
2640 if !track_objective && iter > 5 {
2644 let max_abs_gain = gains
2645 .iter()
2646 .copied()
2647 .fold(0.0f32, |max_gain, gain| max_gain.max(gain.abs()));
2648 if max_abs_gain < 0.001 {
2649 break;
2650 }
2651 }
2652 }
2653
2654 context.progress.partition_finished();
2655}
2656
2657#[inline(never)]
2663fn compute_gains(
2664 docs: &[u32],
2665 fwd: &ForwardIndex,
2666 mid: usize,
2667 degrees: &mut TermDegrees,
2668 log_table: &[f32],
2669 gains: &mut [f32],
2670 allow_inner_parallelism: bool,
2671) {
2672 degrees.refresh_gain_cache(log_table);
2673 debug_assert_eq!(docs.len(), gains.len());
2674 if !degrees.gain_cache.is_empty() {
2675 let cache = degrees.gain_cache.as_slice();
2679 fill_gains(gains, allow_inner_parallelism, |i| {
2680 let side = usize::from(i >= mid);
2681 let mut gain = 0.0f32;
2682 for &term in fwd.doc_terms(docs[i] as usize) {
2683 gain += cache[term as usize][side];
2684 }
2685 gain
2686 });
2687 return;
2688 }
2689
2690 let gain_for_doc = |i: usize| -> f32 {
2699 let doc = docs[i] as usize;
2700 let in_left = i < mid;
2701 let mut g = 0.0f32;
2702 for &term in fwd.doc_terms(doc) {
2703 let [left, right] = degrees.get(term as usize);
2704 let (from, to) = if in_left {
2705 (left, right)
2706 } else {
2707 (right, left)
2708 };
2709 let move_gain = fast_log2_lookup(to as usize + 2, log_table)
2710 - fast_log2_lookup(from as usize, log_table)
2711 - std::f32::consts::LOG2_E / (1.0 + to as f32);
2712 g += if in_left { move_gain } else { -move_gain };
2713 }
2714 g
2715 };
2716
2717 fill_gains(gains, allow_inner_parallelism, gain_for_doc);
2718}
2719
2720fn fill_gains(
2721 gains: &mut [f32],
2722 allow_inner_parallelism: bool,
2723 gain_for_doc: impl Fn(usize) -> f32 + FrequencyParallelSafe,
2724) {
2725 #[cfg(not(feature = "native"))]
2726 let _ = allow_inner_parallelism;
2727
2728 #[cfg(feature = "native")]
2729 {
2730 if allow_inner_parallelism && gains.len() > 4096 {
2731 gains
2732 .par_iter_mut()
2733 .enumerate()
2734 .for_each(|(i, gain)| *gain = gain_for_doc(i));
2735 } else {
2736 for (i, gain) in gains.iter_mut().enumerate() {
2737 *gain = gain_for_doc(i);
2738 }
2739 }
2740 }
2741 #[cfg(not(feature = "native"))]
2742 {
2743 for (i, gain) in gains.iter_mut().enumerate() {
2744 *gain = gain_for_doc(i);
2745 }
2746 }
2747}
2748
2749fn build_log_table(size: usize) -> Vec<f32> {
2753 let mut table = vec![0.0f32; size];
2754 table[0] = -10.0;
2756 for (i, entry) in table.iter_mut().enumerate().skip(1) {
2757 *entry = (i as f32).log2();
2758 }
2759 table
2760}
2761
2762#[inline]
2764fn fast_log2_lookup(val: usize, table: &[f32]) -> f32 {
2765 if val < table.len() {
2766 table[val]
2767 } else {
2768 (val as f32).log2()
2769 }
2770}
2771
2772#[cfg(test)]
2773mod gain_tests;
2774
2775#[cfg(test)]
2776mod tests {
2777 use super::*;
2778
2779 #[test]
2780 fn bounded_frequency_count_and_candidate_selection_are_exact() {
2781 let items: Vec<u32> = (0..10_000).collect();
2782 let frequencies = count_frequencies_bounded(&items, 7, 16 * 1024, |item, counts| {
2783 let term = *item as usize % counts.len();
2784 counts[term] += 1;
2785 Ok(())
2786 })
2787 .unwrap()
2788 .unwrap();
2789 assert_eq!(frequencies.iter().sum::<u32>(), items.len() as u32);
2790 for (term, &frequency) in frequencies.iter().enumerate() {
2791 let expected = (term..items.len()).step_by(frequencies.len()).count() as u32;
2792 assert_eq!(frequency, expected);
2793 }
2794
2795 let (selected, limited) =
2796 select_frequency_candidates(&[8, 3, 0, 5, 3], 1, 10, 2 * CANDIDATE_ENTRY_BYTES);
2797 assert!(limited);
2798 let mut selected = selected;
2799 selected.sort_unstable();
2800 assert_eq!(selected, vec![(1, 3), (4, 3)]);
2801 }
2802
2803 #[test]
2804 fn bounded_frequency_count_rejects_an_undersized_budget() {
2805 assert!(
2806 count_frequencies_bounded(&[0u32], 32, 32, |_, _| Ok(()))
2807 .unwrap()
2808 .is_none(),
2809 "one complete dense frequency table must fit before counting"
2810 );
2811 }
2812
2813 #[test]
2814 fn lazy_term_degrees_initialize_only_on_first_write() {
2815 let mut degrees = TermDegrees::new(130);
2816 let values_ptr = degrees.values.as_ptr();
2817 let bitmap_ptr = degrees.initialized.as_ptr();
2818 assert_eq!(degrees.get(65), [0, 0]);
2819 degrees.entry_mut(65)[0] += 3;
2820 degrees.entry_mut(65)[1] += 2;
2821 assert_eq!(degrees.get(65), [3, 2]);
2822 assert_eq!(degrees.get(64), [0, 0]);
2823 assert_eq!(
2824 degrees
2825 .initialized
2826 .iter()
2827 .map(|w| w.count_ones())
2828 .sum::<u32>(),
2829 1
2830 );
2831
2832 degrees.reset();
2833 assert_eq!(degrees.values.as_ptr(), values_ptr);
2834 assert_eq!(degrees.initialized.as_ptr(), bitmap_ptr);
2835 assert_eq!(degrees.get(65), [0, 0]);
2836 assert!(degrees.touched_words.is_empty());
2837 degrees.entry_mut(129)[1] = 7;
2838 assert_eq!(degrees.get(129), [0, 7]);
2839 assert_eq!(degrees.get(65), [0, 0]);
2840 }
2841
2842 #[test]
2843 fn sparse_objective_keeps_the_original_ascending_term_order() {
2844 let mut degrees = TermDegrees::new(130);
2845 *degrees.entry_mut(129) = [5, 2];
2846 *degrees.entry_mut(1) = [3, 7];
2847 *degrees.entry_mut(65) = [11, 13];
2848 assert_eq!(degrees.touched_words, vec![2, 0, 1]);
2849 degrees.sort_touched_words();
2850 assert_eq!(degrees.touched_words, vec![0, 1, 2]);
2851
2852 let log_table = build_log_table(4096);
2853 let actual = degrees.bisection_objective(31, 32, &log_table);
2854 let side_log = [
2855 fast_log2_lookup(31, &log_table) as f64,
2856 fast_log2_lookup(32, &log_table) as f64,
2857 ];
2858 let mut original_bitmap_scan = 0.0f64;
2859 for (word_idx, &initialized) in degrees.initialized.iter().enumerate() {
2860 let mut pending = initialized;
2861 while pending != 0 {
2862 let bit = pending.trailing_zeros() as usize;
2863 let term = word_idx * 64 + bit;
2864 let [left, right] = degrees.get(term);
2865 for (side, count) in [left, right].into_iter().enumerate() {
2866 if count > 0 {
2867 original_bitmap_scan += count as f64
2868 * (fast_log2_lookup(count as usize + 1, &log_table) as f64
2869 - side_log[side]);
2870 }
2871 }
2872 pending &= pending - 1;
2873 }
2874 }
2875 assert_eq!(actual.to_bits(), original_bitmap_scan.to_bits());
2876 }
2877
2878 #[test]
2879 fn gain_radix_key_matches_total_cmp() {
2880 let values = [
2881 f32::from_bits(0xffc0_0001),
2882 f32::NEG_INFINITY,
2883 -42.0,
2884 -0.0,
2885 0.0,
2886 42.0,
2887 f32::INFINITY,
2888 f32::from_bits(0x7fc0_0001),
2889 ];
2890 let mut by_cmp = values;
2891 by_cmp.sort_by(f32::total_cmp);
2892 let mut by_key = values;
2893 by_key.sort_by_key(|value| gain_order_key(*value));
2894 assert_eq!(
2895 by_cmp.map(f32::to_bits),
2896 by_key.map(f32::to_bits),
2897 "radix selection must preserve the former total_cmp order"
2898 );
2899 }
2900
2901 #[test]
2902 fn radix_threshold_matches_exact_rank_with_ties() {
2903 let gains = [3.0, -1.0, 7.0, -1.0, 0.0, -0.0, 3.0, 9.0, 3.0, 2.0, 2.0];
2904 let mut sorted: Vec<(u32, usize)> = gains
2905 .iter()
2906 .enumerate()
2907 .map(|(idx, &gain)| (gain_order_key(gain), idx))
2908 .collect();
2909 sorted.sort_unstable();
2910
2911 for left_count in 1..=gains.len() {
2912 let (threshold, lower) = select_gain_threshold(&gains, left_count, true);
2913 assert_eq!(threshold, sorted[left_count - 1].0);
2914 assert_eq!(lower, sorted.partition_point(|&(key, _)| key < threshold),);
2915 }
2916 }
2917
2918 #[cfg(feature = "native")]
2919 #[test]
2920 fn parallel_partition_matches_exact_selection_and_degree_rebuild() {
2921 const N: usize = PARALLEL_BP_MIN_ENTITIES + 1;
2922 const TERMS: usize = 101;
2923 let mut terms = Vec::with_capacity(N * 3);
2924 let mut offsets = Vec::with_capacity(N + 1);
2925 offsets.push(0);
2926 for doc in 0..N {
2927 terms.extend_from_slice(&[
2928 (doc % TERMS) as u32,
2929 ((doc / 7) % TERMS) as u32,
2930 ((doc * 13) % TERMS) as u32,
2931 ]);
2932 offsets.push(terms.len() as u64);
2933 }
2934 let fwd = ForwardIndex {
2935 terms,
2936 offsets,
2937 num_terms: TERMS,
2938 parallel_bisect_lanes: 4,
2939 cache_gains: false,
2940 budget_limited: false,
2941 };
2942 let docs: Vec<u32> = (0..N as u32)
2943 .map(|idx| ((idx as usize * 7_919) % N) as u32)
2944 .collect();
2945 let gains: Vec<f32> = docs
2946 .iter()
2947 .map(|&doc| ((doc as usize * 37) % 257) as f32 - 128.0)
2948 .collect();
2949 let mid = N / 2;
2950
2951 let mut ranked: Vec<usize> = (0..N).collect();
2952 ranked.sort_unstable_by(|&left, &right| {
2953 gains[left]
2954 .total_cmp(&gains[right])
2955 .then_with(|| left.cmp(&right))
2956 });
2957 let mut selected_left = vec![false; N];
2958 for &idx in &ranked[..mid] {
2959 selected_left[idx] = true;
2960 }
2961 let expected: Vec<u32> = docs
2962 .iter()
2963 .enumerate()
2964 .filter(|(idx, _)| selected_left[*idx])
2965 .chain(
2966 docs.iter()
2967 .enumerate()
2968 .filter(|(idx, _)| !selected_left[*idx]),
2969 )
2970 .map(|(_, &doc)| doc)
2971 .collect();
2972
2973 let mut output = vec![0; N];
2974 let mut ranked_scratch = Vec::new();
2975 let mut movement_workspaces: Vec<_> = (0..3).map(|_| TermDegrees::new(TERMS)).collect();
2976 let outcome = partition_by_gain(
2977 &docs,
2978 &gains,
2979 mid,
2980 &fwd,
2981 &mut movement_workspaces,
2982 &mut output,
2983 &mut ranked_scratch,
2984 true,
2985 );
2986 assert_eq!(output, expected);
2987 assert!(
2988 matches!(&outcome.degree_update, PartitionDegreeUpdate::Moves),
2989 "test must exercise parallel movement counts"
2990 );
2991
2992 let mut updated_workspaces: Vec<_> = (0..4).map(|_| TermDegrees::new(TERMS)).collect();
2993 build_term_degrees(&docs, mid, &fwd, &mut updated_workspaces, None);
2994 movement_workspaces[0].apply_moves_to(&mut updated_workspaces[0]);
2995
2996 let mut rebuilt_workspaces: Vec<_> = (0..4).map(|_| TermDegrees::new(TERMS)).collect();
2997 build_term_degrees(&output, mid, &fwd, &mut rebuilt_workspaces, None);
2998 for term in 0..TERMS {
2999 assert_eq!(
3000 updated_workspaces[0].get(term),
3001 rebuilt_workspaces[0].get(term),
3002 "parallel movement-count mismatch for term {term}"
3003 );
3004 }
3005 }
3006
3007 #[test]
3013 fn test_csr_offsets_do_not_wrap_past_u32() {
3014 let counts = [1_500_000_000u32; 3]; let offsets = build_csr_offsets(&counts, &|| Ok(())).unwrap();
3016 assert_eq!(
3017 offsets,
3018 vec![0, 1_500_000_000, 3_000_000_000, 4_500_000_000]
3019 );
3020 assert!(*offsets.last().unwrap() > u32::MAX as u64);
3021 }
3022
3023 fn make_fwd(docs: &[&[u32]], num_terms: usize) -> ForwardIndex {
3025 let mut terms = Vec::new();
3026 let mut offsets = vec![0u64];
3027 for doc_terms in docs {
3028 terms.extend_from_slice(doc_terms);
3029 offsets.push(terms.len() as u64);
3030 }
3031 ForwardIndex {
3032 terms,
3033 offsets,
3034 num_terms,
3035 parallel_bisect_lanes: 1,
3036 cache_gains: false,
3037 budget_limited: false,
3038 }
3039 }
3040
3041 #[test]
3042 fn level_partition_ranges_match_recursive_halving() {
3043 for total in 1..=129 {
3044 let mut expected: Vec<std::ops::Range<usize>> = std::iter::once(0..total).collect();
3045 for level in 0..8 {
3046 let actual: Vec<_> = (0..(1usize << level))
3047 .map(|partition_id| partition_range(total, level, partition_id))
3048 .collect();
3049 assert_eq!(actual, expected, "total={total}, level={level}");
3050
3051 expected = expected
3052 .into_iter()
3053 .flat_map(|range| {
3054 let mid = range.start + range.len() / 2;
3055 [range.start..mid, mid..range.end]
3056 })
3057 .collect();
3058 }
3059 }
3060 }
3061
3062 #[cfg(feature = "native")]
3063 #[test]
3064 fn level_synchronized_scheduler_is_thread_count_deterministic() {
3065 let docs: Vec<Vec<u32>> = (0..1_027)
3066 .map(|doc| {
3067 vec![
3068 (doc % 31) as u32,
3069 ((doc / 5) % 53) as u32,
3070 ((doc * 29 + 3) % 97) as u32,
3071 ]
3072 })
3073 .collect();
3074 let doc_refs: Vec<_> = docs.iter().map(Vec::as_slice).collect();
3075 let mut fwd = make_fwd(&doc_refs, 97);
3076 fwd.parallel_bisect_lanes = 8;
3077 let one_thread = rayon::ThreadPoolBuilder::new()
3078 .num_threads(1)
3079 .build()
3080 .unwrap();
3081 let eight_threads = rayon::ThreadPoolBuilder::new()
3082 .num_threads(8)
3083 .build()
3084 .unwrap();
3085
3086 let one = one_thread.install(|| graph_bisection(&fwd, 8, 12, BpBudget::full()).0);
3087 let eight = eight_threads.install(|| graph_bisection(&fwd, 8, 12, BpBudget::full()).0);
3088
3089 assert_eq!(eight, one);
3090 }
3091
3092 #[cfg(feature = "native")]
3096 #[test]
3097 #[ignore]
3098 fn bench_level_synchronized_scheduler() {
3099 const DOCS: usize = 500_000;
3100 const TERMS: usize = 32_768;
3101 const ROUNDS: usize = 3;
3102
3103 let mut terms = Vec::with_capacity(DOCS * 12);
3104 let mut offsets = Vec::with_capacity(DOCS + 1);
3105 offsets.push(0);
3106 for doc in 0..DOCS {
3107 let term_count = 4 + ((doc.wrapping_mul(2_654_435_761) >> 12) % 17);
3111 for term in 0..term_count {
3112 terms.push(
3113 ((doc / 64)
3114 .wrapping_mul(131)
3115 .wrapping_add(term.wrapping_mul(7_919))
3116 % TERMS) as u32,
3117 );
3118 }
3119 offsets.push(terms.len() as u64);
3120 }
3121 let fwd = ForwardIndex {
3122 terms,
3123 offsets,
3124 num_terms: TERMS,
3125 parallel_bisect_lanes: 8,
3126 cache_gains: false,
3127 budget_limited: false,
3128 };
3129 let pool = rayon::ThreadPoolBuilder::new()
3130 .num_threads(8)
3131 .build()
3132 .unwrap();
3133 let mut level_times = Vec::with_capacity(ROUNDS);
3134 let mut expected_output = None;
3135
3136 pool.install(|| {
3137 for _ in 0..ROUNDS {
3138 let started = std::time::Instant::now();
3139 let output = graph_bisection(&fwd, 32, 12, BpBudget::full()).0;
3140 level_times.push(started.elapsed());
3141 if let Some(expected) = &expected_output {
3142 assert_eq!(&output, expected);
3143 } else {
3144 expected_output = Some(output);
3145 }
3146 }
3147 });
3148
3149 level_times.sort_unstable();
3150 let level = level_times[ROUNDS / 2];
3151 println!(
3152 "BP level-synchronized scheduler median: {:.3}s",
3153 level.as_secs_f64(),
3154 );
3155 }
3156
3157 #[test]
3158 fn test_bp_empty() {
3159 let fwd = ForwardIndex {
3160 terms: Vec::new(),
3161 offsets: Vec::new(),
3162 num_terms: 0,
3163 parallel_bisect_lanes: 1,
3164 cache_gains: false,
3165 budget_limited: false,
3166 };
3167 let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
3168 assert!(perm.is_empty());
3169 }
3170
3171 #[test]
3172 fn test_bp_small() {
3173 let fwd = make_fwd(&[&[0, 1], &[0, 2], &[1, 3], &[2, 3]], 4);
3175 let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
3176 assert_eq!(perm.len(), 4);
3177 let mut sorted = perm.clone();
3179 sorted.sort();
3180 assert_eq!(sorted, vec![0, 1, 2, 3]);
3181 }
3182
3183 #[test]
3184 fn test_bp_clusters() {
3185 let fwd = make_fwd(
3189 &[
3190 &[0, 1],
3191 &[0, 1],
3192 &[0, 1],
3193 &[0, 1],
3194 &[2, 3],
3195 &[2, 3],
3196 &[2, 3],
3197 &[2, 3],
3198 ],
3199 4,
3200 );
3201 let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
3202 assert_eq!(perm.len(), 8);
3203
3204 let left: Vec<u32> = perm[..4].to_vec();
3206
3207 let a_in_left = left.iter().filter(|&&d| d < 4).count();
3209 let b_in_left = left.iter().filter(|&&d| d >= 4).count();
3210 assert!(
3211 (a_in_left == 4 && b_in_left == 0) || (a_in_left == 0 && b_in_left == 4),
3212 "Clusters should be separated: a_left={}, b_left={}",
3213 a_in_left,
3214 b_in_left,
3215 );
3216 }
3217
3218 #[test]
3219 fn test_bp_permutation_valid() {
3220 let docs: Vec<Vec<u32>> = (0..16).map(|i| vec![i / 4, 10 + i / 2]).collect();
3222 let doc_refs: Vec<&[u32]> = docs.iter().map(|v| v.as_slice()).collect();
3223 let fwd = make_fwd(&doc_refs, 18); let (perm, _) = graph_bisection(&fwd, 4, 20, BpBudget::full());
3225
3226 assert_eq!(perm.len(), 16);
3227 let mut sorted = perm.clone();
3229 sorted.sort();
3230 let expected: Vec<u32> = (0..16).collect();
3231 assert_eq!(sorted, expected);
3232 }
3233
3234 #[test]
3239 fn test_bp_depth_cap_separates_clusters_and_converges() {
3240 let fwd = make_fwd(
3243 &[
3244 &[0, 1],
3245 &[0, 1],
3246 &[0, 1],
3247 &[2, 3],
3248 &[0, 1],
3249 &[2, 3],
3250 &[2, 3],
3251 &[2, 3],
3252 ],
3253 4,
3254 );
3255 let budget = BpBudget {
3256 min_partition_docs: Some(4),
3257 time_budget: None,
3258 };
3259 let (perm, converged) = graph_bisection(&fwd, 2, 20, budget);
3260 assert!(converged, "depth cap must report converged");
3261 assert_eq!(perm.len(), 8);
3262 let mut sorted = perm.clone();
3263 sorted.sort();
3264 assert_eq!(
3265 sorted,
3266 (0..8).collect::<Vec<u32>>(),
3267 "must stay a valid permutation"
3268 );
3269 let cluster_a = [0u32, 1, 2, 4];
3272 let a_in_left = perm[..4].iter().filter(|d| cluster_a.contains(d)).count();
3273 assert!(
3274 a_in_left == 4 || a_in_left == 0,
3275 "clusters should separate at the top level: {:?}",
3276 perm
3277 );
3278 }
3279
3280 #[test]
3283 fn test_bp_zero_time_budget_emits_valid_partial_permutation() {
3284 let docs: Vec<Vec<u32>> = (0..64).map(|i| vec![i % 4]).collect();
3285 let doc_refs: Vec<&[u32]> = docs.iter().map(|v| v.as_slice()).collect();
3286 let fwd = make_fwd(&doc_refs, 4);
3287 let budget = BpBudget {
3288 min_partition_docs: None,
3289 time_budget: Some(std::time::Duration::ZERO),
3290 };
3291 let (perm, converged) = graph_bisection(&fwd, 4, 20, budget);
3292 assert!(!converged, "zero budget must report unconverged");
3293 assert_eq!(perm.len(), 64);
3294 let mut sorted = perm.clone();
3295 sorted.sort();
3296 assert_eq!(sorted, (0..64).collect::<Vec<u32>>());
3297 }
3298
3299 #[test]
3300 fn test_bp_shutdown_cancellation_emits_valid_partial_permutation() {
3301 let docs: Vec<Vec<u32>> = (0..64).map(|i| vec![i % 4]).collect();
3302 let doc_refs: Vec<&[u32]> = docs.iter().map(Vec::as_slice).collect();
3303 let fwd = make_fwd(&doc_refs, 4);
3304 let cancellation = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
3305 let budget = BpBudget {
3306 min_partition_docs: None,
3307 time_budget: None,
3308 };
3309
3310 let (perm, converged) = graph_bisection_with_progress(
3311 &fwd,
3312 4,
3313 20,
3314 budget,
3315 Some(cancellation.as_ref()),
3316 BpProgressLabel::anonymous(),
3317 );
3318
3319 assert!(!converged, "cancelled BP must report unconverged");
3320 assert_eq!(perm, (0..64).collect::<Vec<u32>>());
3321 }
3322
3323 #[test]
3324 fn test_memory_limited_graph_never_reports_converged() {
3325 let mut fwd = make_fwd(&[&[0], &[0], &[1], &[1]], 2);
3326 fwd.budget_limited = true;
3327
3328 let (perm, converged) = graph_bisection(&fwd, 2, 20, BpBudget::full());
3329
3330 assert!(!converged);
3331 let mut sorted = perm;
3332 sorted.sort_unstable();
3333 assert_eq!(sorted, vec![0, 1, 2, 3]);
3334 }
3335
3336 #[test]
3337 fn test_fast_log2() {
3338 let table = build_log_table(4096);
3339 assert!((table[1] - 0.0).abs() < 0.001);
3340 assert!((table[2] - 1.0).abs() < 0.001);
3341 assert!((table[4] - 2.0).abs() < 0.001);
3342 assert!((table[1024] - 10.0).abs() < 0.001);
3343 let val = fast_log2_lookup(8192, &table);
3345 assert!((val - 13.0).abs() < 0.001);
3346 }
3347}