Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82541da476 |
@@ -1,5 +1,5 @@
|
||||
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
|
||||
use fff_search::types::{BigramFilter, BigramIndexBuilder};
|
||||
use fff_search::bigram_filter::{BigramFilter, BigramIndexBuilder};
|
||||
|
||||
/// Build a realistic bigram index for benchmarking.
|
||||
/// Simulates a large repo by generating varied content per file.
|
||||
@@ -53,7 +53,14 @@ fn bench_bigram_query(c: &mut Criterion) {
|
||||
|
||||
fn bench_bigram_is_candidate(c: &mut Criterion) {
|
||||
let index = build_test_index(500_000);
|
||||
let candidates = index.query(b"struct").unwrap();
|
||||
let candidates = match index.query(b"struct") {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
// All bigrams ubiquitous at this size — skip candidate benches
|
||||
eprintln!("Skipping is_candidate bench: query returned None (all bigrams ubiquitous)");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
c.bench_function("is_candidate_500k", |b| {
|
||||
b.iter(|| {
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicU16, AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
use ahash::AHashMap;
|
||||
|
||||
/// Maximum number of distinct bigrams tracked in the inverted index.
|
||||
/// 95 printable ASCII chars (32..=126) after lowercasing → ~70 distinct → 4900 possible.
|
||||
/// We cap at 5000 to cover all printable bigrams with margin.
|
||||
/// 5000 columns × 62.5KB (500k files) = 305MB. For 50k files: 30MB.
|
||||
const MAX_BIGRAM_COLUMNS: usize = 5000;
|
||||
|
||||
/// Sentinel value: bigram has no allocated column.
|
||||
const NO_COLUMN: u16 = u16::MAX;
|
||||
|
||||
/// Temporary sync dense builder for the bigram index.
|
||||
/// Uses AtomicU64 for lock-free concurrent writes during the parallel build phase.
|
||||
pub struct BigramIndexBuilder {
|
||||
// we use lookup as atomics only in the builder because it is filled by the rayon threads
|
||||
// the actual index uses pure u16 for the allocations
|
||||
lookup: Vec<AtomicU16>,
|
||||
/// Per-column bitset data, lazily allocated via OnceLock.
|
||||
col_data: Vec<OnceLock<Box<[AtomicU64]>>>,
|
||||
next_column: AtomicU16,
|
||||
words: usize,
|
||||
file_count: usize,
|
||||
populated: AtomicUsize,
|
||||
}
|
||||
|
||||
impl BigramIndexBuilder {
|
||||
pub fn new(file_count: usize) -> Self {
|
||||
let words = file_count.div_ceil(64);
|
||||
let mut lookup = Vec::with_capacity(65536);
|
||||
lookup.resize_with(65536, || AtomicU16::new(NO_COLUMN));
|
||||
let mut col_data = Vec::with_capacity(MAX_BIGRAM_COLUMNS);
|
||||
col_data.resize_with(MAX_BIGRAM_COLUMNS, OnceLock::new);
|
||||
Self {
|
||||
lookup,
|
||||
col_data,
|
||||
next_column: AtomicU16::new(0),
|
||||
words,
|
||||
file_count,
|
||||
populated: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn get_or_alloc_column(&self, key: u16) -> u16 {
|
||||
let current = self.lookup[key as usize].load(Ordering::Relaxed);
|
||||
if current != NO_COLUMN {
|
||||
return current;
|
||||
}
|
||||
let new_col = self.next_column.fetch_add(1, Ordering::Relaxed);
|
||||
if new_col >= MAX_BIGRAM_COLUMNS as u16 {
|
||||
return NO_COLUMN;
|
||||
}
|
||||
|
||||
match self.lookup[key as usize].compare_exchange(
|
||||
NO_COLUMN,
|
||||
new_col,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => new_col,
|
||||
Err(existing) => existing,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn column_bitset(&self, col: u16) -> &[AtomicU64] {
|
||||
let words = self.words;
|
||||
self.col_data[col as usize].get_or_init(|| {
|
||||
let mut v = Vec::with_capacity(words);
|
||||
v.resize_with(words, || AtomicU64::new(0));
|
||||
v.into_boxed_slice()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_file_content(&self, file_idx: usize, content: &[u8]) {
|
||||
if content.len() < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
debug_assert!(file_idx < self.file_count);
|
||||
let word_idx = file_idx / 64;
|
||||
let bit_mask = 1u64 << (file_idx % 64);
|
||||
|
||||
let mut prev = content[0];
|
||||
for &b in &content[1..] {
|
||||
if (32..=126).contains(&prev) && (32..=126).contains(&b) {
|
||||
let key = (prev.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
|
||||
let col = self.get_or_alloc_column(key);
|
||||
if col != NO_COLUMN {
|
||||
self.column_bitset(col)[word_idx].fetch_or(bit_mask, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
prev = b;
|
||||
}
|
||||
self.populated.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Index skip-1 bigrams (stride 2) for a single file.
|
||||
///
|
||||
/// For content "ABCDE" this extracts pairs (A,C), (B,D), (C,E).
|
||||
/// These capture non-adjacent character relationships that are largely
|
||||
/// independent from consecutive bigrams, enabling much tighter candidate
|
||||
/// filtering when ANDead together.
|
||||
pub fn add_file_content_skip(&self, file_idx: usize, content: &[u8]) {
|
||||
if content.len() < 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
debug_assert!(file_idx < self.file_count);
|
||||
let word_idx = file_idx / 64;
|
||||
let bit_mask = 1u64 << (file_idx % 64);
|
||||
|
||||
for i in 0..content.len() - 2 {
|
||||
let a = content[i];
|
||||
let b = content[i + 2];
|
||||
if (32..=126).contains(&a) && (32..=126).contains(&b) {
|
||||
let key = (a.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
|
||||
let col = self.get_or_alloc_column(key);
|
||||
if col != NO_COLUMN {
|
||||
self.column_bitset(col)[word_idx].fetch_or(bit_mask, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.populated.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.populated.load(Ordering::Relaxed) > 0
|
||||
}
|
||||
|
||||
pub fn columns_used(&self) -> u16 {
|
||||
self.next_column
|
||||
.load(Ordering::Relaxed)
|
||||
.min(MAX_BIGRAM_COLUMNS as u16)
|
||||
}
|
||||
|
||||
/// Compress the dense builder into a compact `BigramFilter`.
|
||||
///
|
||||
/// Retains columns where the bigram appears in ≥`min_density_pct`% (or
|
||||
/// the default ~3.1% heuristic when `None`) and <90% of indexed files.
|
||||
/// Sparse columns carry too little data to justify their memory;
|
||||
/// ubiquitous columns (≥90%) are nearly all-ones and barely filter.
|
||||
///
|
||||
/// Each column's `Box<[AtomicU64]>` (~60 KB for 500k files) is freed
|
||||
/// immediately after compression via `OnceLock::take`, so peak memory
|
||||
/// during compress is roughly `max(builder, result)` instead of
|
||||
/// `builder + result`.
|
||||
pub fn compress(self, min_density_pct: Option<u32>) -> BigramFilter {
|
||||
let cols = self.columns_used() as usize;
|
||||
let words = self.words;
|
||||
let file_count = self.file_count;
|
||||
let populated = self.populated.load(Ordering::Relaxed);
|
||||
let dense_bytes = words * 8; // cost of one dense column
|
||||
|
||||
// Destructure so we can incrementally free col_data entries.
|
||||
let old_lookup = self.lookup;
|
||||
let mut col_data = self.col_data;
|
||||
|
||||
let mut lookup: Vec<u16> = vec![NO_COLUMN; 65536];
|
||||
let mut dense_data: Vec<u64> = Vec::with_capacity(cols * words);
|
||||
let mut dense_count: usize = 0;
|
||||
|
||||
for key in 0..65536usize {
|
||||
let old_col = old_lookup[key].load(Ordering::Relaxed);
|
||||
if old_col == NO_COLUMN || old_col as usize >= cols {
|
||||
continue;
|
||||
}
|
||||
let Some(bitset) = col_data[old_col as usize].take() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Count set bits to decide if this column is worth keeping.
|
||||
let mut popcount = 0u32;
|
||||
for w in 0..words {
|
||||
popcount += bitset[w].load(Ordering::Relaxed).count_ones();
|
||||
}
|
||||
|
||||
// Sparse threshold — drop bigrams appearing in too few files.
|
||||
let sparse_ok = if let Some(min_pct) = min_density_pct {
|
||||
// Percentage-based: require ≥ min_pct% of populated files.
|
||||
populated > 0 && (popcount as usize) * 100 >= populated * min_pct as usize
|
||||
} else {
|
||||
// Default heuristic: popcount ≥ words × 2 (~3.1% of files).
|
||||
(popcount as usize * 4) >= dense_bytes
|
||||
};
|
||||
if !sparse_ok {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drop ubiquitous bigrams — columns ≥90% ones carry almost no
|
||||
// filtering power and just waste memory + AND cycles.
|
||||
if populated > 0 && (popcount as usize) * 10 >= populated * 9 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dense_idx = dense_count as u16;
|
||||
lookup[key] = dense_idx;
|
||||
dense_count += 1;
|
||||
|
||||
for w in 0..words {
|
||||
dense_data.push(bitset[w].load(Ordering::Relaxed));
|
||||
}
|
||||
}
|
||||
|
||||
drop(col_data);
|
||||
drop(old_lookup);
|
||||
|
||||
BigramFilter {
|
||||
lookup,
|
||||
dense_data,
|
||||
dense_count,
|
||||
words,
|
||||
file_count,
|
||||
populated,
|
||||
skip_index: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl Send for BigramIndexBuilder {}
|
||||
unsafe impl Sync for BigramIndexBuilder {}
|
||||
|
||||
/// Inverted bigram index with optional "skip-1" extension
|
||||
/// Copmressed into bitset for minimal usage, the layout of this struct actually matters
|
||||
#[derive(Debug)]
|
||||
pub struct BigramFilter {
|
||||
lookup: Vec<u16>,
|
||||
/// Flat buffer of all dense column data laid out at fixed stride `words`.
|
||||
/// Column `i` starts at `i * words`.
|
||||
dense_data: Vec<u64>, // do not try to change this to u8 it has to be wordsize
|
||||
dense_count: usize,
|
||||
words: usize,
|
||||
file_count: usize,
|
||||
populated: usize,
|
||||
/// Optional skip-1 bigram index (stride 2). Built from character pairs
|
||||
/// at distance 2, e.g. "ABCDE" → (A,C),(B,D),(C,E). ANDead with the
|
||||
/// consecutive bigram candidates during query to dramatically reduce
|
||||
/// false positives.
|
||||
skip_index: Option<Box<BigramFilter>>,
|
||||
}
|
||||
|
||||
/// SIMD-friendly bitwise AND of two equal-length bitsets.
|
||||
// Auto vectorized (don't touch)
|
||||
#[inline]
|
||||
fn bitset_and(result: &mut [u64], bitset: &[u64]) {
|
||||
result
|
||||
.iter_mut()
|
||||
.zip(bitset.iter())
|
||||
.for_each(|(r, b)| *r &= *b);
|
||||
}
|
||||
|
||||
impl BigramFilter {
|
||||
/// AND the posting lists for all query bigrams (consecutive + skip).
|
||||
/// Returns None if no query bigrams are tracked.
|
||||
pub fn query(&self, pattern: &[u8]) -> Option<Vec<u64>> {
|
||||
if pattern.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut result = vec![u64::MAX; self.words];
|
||||
if !self.file_count.is_multiple_of(64) {
|
||||
let last = self.words - 1;
|
||||
result[last] = (1u64 << (self.file_count % 64)) - 1;
|
||||
}
|
||||
|
||||
let words = self.words;
|
||||
let mut has_filter = false;
|
||||
|
||||
let mut prev = pattern[0];
|
||||
for &b in &pattern[1..] {
|
||||
if (32..=126).contains(&prev) && (32..=126).contains(&b) {
|
||||
let key = (prev.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
|
||||
let col = self.lookup[key as usize];
|
||||
if col != NO_COLUMN {
|
||||
let offset = col as usize * words;
|
||||
// SAFETY: compress() guarantees offset + words <= dense_data.len()
|
||||
let slice = unsafe { self.dense_data.get_unchecked(offset..offset + words) };
|
||||
bitset_and(&mut result, slice);
|
||||
has_filter = true;
|
||||
}
|
||||
}
|
||||
prev = b;
|
||||
}
|
||||
|
||||
// strid-1 bigrams
|
||||
if let Some(skip) = &self.skip_index
|
||||
&& pattern.len() >= 3
|
||||
&& let Some(skip_candidates) = skip.query_skip(pattern)
|
||||
{
|
||||
bitset_and(&mut result, &skip_candidates);
|
||||
has_filter = true;
|
||||
}
|
||||
|
||||
has_filter.then_some(result)
|
||||
}
|
||||
|
||||
/// Query using stride-2 bigrams from the pattern.
|
||||
/// For "ABCDE" queries with keys (A,C), (B,D), (C,E).
|
||||
fn query_skip(&self, pattern: &[u8]) -> Option<Vec<u64>> {
|
||||
let mut result = vec![u64::MAX; self.words];
|
||||
if !self.file_count.is_multiple_of(64) {
|
||||
let last = self.words - 1;
|
||||
result[last] = (1u64 << (self.file_count % 64)) - 1;
|
||||
}
|
||||
|
||||
let words = self.words;
|
||||
let mut has_filter = false;
|
||||
|
||||
for i in 0..pattern.len().saturating_sub(2) {
|
||||
let a = pattern[i];
|
||||
let b = pattern[i + 2];
|
||||
if (32..=126).contains(&a) && (32..=126).contains(&b) {
|
||||
let key = (a.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
|
||||
let col = self.lookup[key as usize];
|
||||
if col != NO_COLUMN {
|
||||
let offset = col as usize * words;
|
||||
let slice = unsafe { self.dense_data.get_unchecked(offset..offset + words) };
|
||||
bitset_and(&mut result, slice);
|
||||
has_filter = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
has_filter.then_some(result)
|
||||
}
|
||||
|
||||
/// Attach a skip-1 bigram index for tighter candidate filtering.
|
||||
pub fn set_skip_index(&mut self, skip: BigramFilter) {
|
||||
self.skip_index = Some(Box::new(skip));
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_candidate(candidates: &[u64], file_idx: usize) -> bool {
|
||||
let word = file_idx / 64;
|
||||
let bit = file_idx % 64;
|
||||
word < candidates.len() && candidates[word] & (1u64 << bit) != 0
|
||||
}
|
||||
|
||||
pub fn count_candidates(candidates: &[u64]) -> usize {
|
||||
candidates.iter().map(|w| w.count_ones() as usize).sum()
|
||||
}
|
||||
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.populated > 0
|
||||
}
|
||||
|
||||
pub fn file_count(&self) -> usize {
|
||||
self.file_count
|
||||
}
|
||||
|
||||
pub fn columns_used(&self) -> usize {
|
||||
self.dense_count
|
||||
}
|
||||
|
||||
/// Total heap bytes used by this index (lookup + dense data + skip).
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
let lookup_bytes = self.lookup.len() * std::mem::size_of::<u16>();
|
||||
let dense_bytes = self.dense_data.len() * std::mem::size_of::<u64>();
|
||||
let skip_bytes = self.skip_index.as_ref().map_or(0, |s| s.heap_bytes());
|
||||
lookup_bytes + dense_bytes + skip_bytes
|
||||
}
|
||||
|
||||
/// Check whether a bigram key is present in this index.
|
||||
pub fn has_key(&self, key: u16) -> bool {
|
||||
self.lookup[key as usize] != NO_COLUMN
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_bigrams(content: &[u8]) -> Vec<u16> {
|
||||
if content.len() < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
// Use a flat bitset (65536 bits = 8 KB) for dedup — faster than HashSet.
|
||||
let mut seen = vec![0u64; 1024]; // 1024 * 64 = 65536 bits
|
||||
let mut bigrams = Vec::new();
|
||||
|
||||
let mut prev = content[0];
|
||||
for &b in &content[1..] {
|
||||
if (32..=126).contains(&prev) && (32..=126).contains(&b) {
|
||||
let key = (prev.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
|
||||
let word = key as usize / 64;
|
||||
let bit = 1u64 << (key as usize % 64);
|
||||
if seen[word] & bit == 0 {
|
||||
seen[word] |= bit;
|
||||
bigrams.push(key);
|
||||
}
|
||||
}
|
||||
prev = b;
|
||||
}
|
||||
bigrams
|
||||
}
|
||||
|
||||
/// Modified and added files store their own bigram sets. Deleted files are
|
||||
/// tombstoned in a bitset so they can be excluded from base query results.
|
||||
/// This overlay is updated by the background watcher on every file event
|
||||
/// and cleared when the base index is rebuilt.
|
||||
#[derive(Debug)]
|
||||
pub struct BigramOverlay {
|
||||
/// Per-file bigram sets for files modified since the base was built.
|
||||
/// Key = file index in the base `Vec<FileItem>`.
|
||||
modified: AHashMap<usize, Vec<u16>>,
|
||||
|
||||
/// Tombstone bitset — one bit per base file. Set bits are excluded
|
||||
/// from base query results.
|
||||
tombstones: Vec<u64>,
|
||||
|
||||
/// Bigram sets for files added after the base was built (overflow files).
|
||||
added: Vec<Vec<u16>>,
|
||||
|
||||
/// Original files count this overlay was created for.
|
||||
base_file_count: usize,
|
||||
}
|
||||
|
||||
impl BigramOverlay {
|
||||
pub(crate) fn new(base_file_count: usize) -> Self {
|
||||
let words = base_file_count.div_ceil(64);
|
||||
Self {
|
||||
modified: AHashMap::new(),
|
||||
tombstones: vec![0u64; words],
|
||||
added: Vec::new(),
|
||||
base_file_count,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn add_file(&mut self, content: &[u8]) {
|
||||
self.added.push(extract_bigrams(content));
|
||||
}
|
||||
|
||||
pub(crate) fn modify_file(&mut self, file_idx: usize, content: &[u8]) {
|
||||
self.modified.insert(file_idx, extract_bigrams(content));
|
||||
}
|
||||
|
||||
pub(crate) fn delete_file(&mut self, file_idx: usize) {
|
||||
if file_idx < self.base_file_count {
|
||||
let word = file_idx / 64;
|
||||
self.tombstones[word] |= 1u64 << (file_idx % 64);
|
||||
}
|
||||
self.modified.remove(&file_idx);
|
||||
}
|
||||
|
||||
/// Return base file indices of modified files whose bigrams match ALL
|
||||
/// of the given `pattern_bigrams`.
|
||||
pub(crate) fn query_modified(&self, pattern_bigrams: &[u16]) -> Vec<usize> {
|
||||
if pattern_bigrams.is_empty() {
|
||||
return self.modified.keys().copied().collect();
|
||||
}
|
||||
self.modified
|
||||
.iter()
|
||||
.filter_map(|(&file_idx, bigrams)| {
|
||||
pattern_bigrams
|
||||
.iter()
|
||||
.all(|pb| bigrams.contains(pb))
|
||||
.then_some(file_idx)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// TODO implement the bigram for overlays as well
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn query_added(&self, pattern_bigrams: &[u16]) -> Vec<usize> {
|
||||
if pattern_bigrams.is_empty() {
|
||||
return (0..self.added.len()).collect();
|
||||
}
|
||||
self.added
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, bigrams)| {
|
||||
pattern_bigrams
|
||||
.iter()
|
||||
.all(|pb| bigrams.contains(pb))
|
||||
.then_some(idx)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the tombstone bitset for clearing base candidates.
|
||||
pub(crate) fn tombstones(&self) -> &[u64] {
|
||||
&self.tombstones
|
||||
}
|
||||
|
||||
/// Remove an overflow entry by index (when the file is deleted).
|
||||
pub(crate) fn remove_added(&mut self, idx: usize) {
|
||||
if idx < self.added.len() {
|
||||
self.added.remove(idx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update an existing overflow entry's bigrams.
|
||||
pub(crate) fn update_added(&mut self, idx: usize, bigrams: Vec<u16>) {
|
||||
if idx < self.added.len() {
|
||||
self.added[idx] = bigrams;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
//! Implementations (fastest → simplest):
|
||||
//! - `search_packed_pair`: AVX2 packed-pair scan (two rare bytes at known offsets)
|
||||
//! - `search`: memchr2 first-byte scan + verify
|
||||
//! - `search_scalar`: memchr2 first-byte scan + scalar verify (baseline)
|
||||
//!
|
||||
//! The packed-pair approach mirrors what `memchr::memmem` does internally for
|
||||
//! case-sensitive search — pick two rare bytes from the needle, SIMD-scan for
|
||||
@@ -562,34 +561,7 @@ pub fn search(haystack: &[u8], needle_lower: &[u8]) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn search_scalar(haystack: &[u8], needle_lower: &[u8]) -> bool {
|
||||
let n = needle_lower.len();
|
||||
if n == 0 {
|
||||
return true;
|
||||
}
|
||||
if n > haystack.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let search_space = &haystack[..=haystack.len() - n];
|
||||
let first = needle_lower[0];
|
||||
|
||||
if first.is_ascii_lowercase() {
|
||||
let alt = ascii_swap_case(first);
|
||||
for pos in memchr::memchr2_iter(first, alt, search_space) {
|
||||
if unsafe { verify_scalar(haystack.as_ptr().add(pos), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for pos in memchr::memchr_iter(first, search_space) {
|
||||
if unsafe { verify_scalar(haystack.as_ptr().add(pos), needle_lower) } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
//! the file index, so read-heavy search workloads rarely contend.
|
||||
|
||||
use crate::background_watcher::BackgroundWatcher;
|
||||
use crate::bigram_filter::{BigramFilter, BigramIndexBuilder, BigramOverlay};
|
||||
use crate::error::Error;
|
||||
use crate::frecency::FrecencyTracker;
|
||||
use crate::git::GitStatusCache;
|
||||
@@ -38,10 +39,7 @@ use crate::grep::{GrepResult, GrepSearchOptions, grep_search};
|
||||
use crate::query_tracker::QueryTracker;
|
||||
use crate::score::match_and_score_files;
|
||||
use crate::shared::{SharedFrecency, SharedPicker};
|
||||
use crate::types::{
|
||||
BigramFilter, BigramIndexBuilder, BigramOverlay, ContentCacheBudget, FileItem, PaginationArgs,
|
||||
ScoringContext, SearchResult,
|
||||
};
|
||||
use crate::types::{ContentCacheBudget, FileItem, PaginationArgs, ScoringContext, SearchResult};
|
||||
use fff_query_parser::FFFQuery;
|
||||
use git2::{Repository, Status, StatusOptions};
|
||||
use rayon::prelude::*;
|
||||
@@ -804,7 +802,7 @@ impl FilePicker {
|
||||
&& let Ok(content) = std::fs::read(path)
|
||||
{
|
||||
let overflow_pos = abs_pos - self.sync_data.base_count;
|
||||
let bigrams = crate::types::extract_bigrams(&content);
|
||||
let bigrams = crate::bigram_filter::extract_bigrams(&content);
|
||||
overlay.write().update_added(overflow_pos, bigrams);
|
||||
}
|
||||
return Some(&self.sync_data.files[abs_pos]);
|
||||
|
||||
@@ -5,13 +5,19 @@
|
||||
//! performance — the most relevant files are searched first, enabling early
|
||||
//! termination once enough results are collected.
|
||||
|
||||
use crate::constraints::apply_constraints;
|
||||
use crate::sort_buffer::sort_with_buffer;
|
||||
use crate::types::{BigramFilter, BigramOverlay, ContentCacheBudget, FileItem, extract_bigrams};
|
||||
use crate::{
|
||||
BigramFilter, BigramOverlay,
|
||||
constraints::apply_constraints,
|
||||
extract_bigrams,
|
||||
sort_buffer::sort_with_buffer,
|
||||
types::{ContentCacheBudget, FileItem},
|
||||
};
|
||||
use aho_corasick::AhoCorasick;
|
||||
use fff_grep::lines::{self, LineStep};
|
||||
use fff_grep::matcher::{Match, Matcher, NoError};
|
||||
use fff_grep::{Searcher, SearcherBuilder, Sink, SinkMatch};
|
||||
pub use fff_grep::{
|
||||
Searcher, SearcherBuilder, Sink, SinkMatch,
|
||||
lines::{self, LineStep},
|
||||
matcher::{Match, Matcher, NoError},
|
||||
};
|
||||
use fff_query_parser::{Constraint, FFFQuery, GrepConfig, QueryParser};
|
||||
use rayon::prelude::*;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
@@ -93,12 +93,14 @@
|
||||
//! ```
|
||||
|
||||
mod background_watcher;
|
||||
pub mod case_insensitive_memmem;
|
||||
mod bigram_filter;
|
||||
mod constraints;
|
||||
mod db_healthcheck;
|
||||
mod error;
|
||||
mod score;
|
||||
mod sort_buffer;
|
||||
// this is pub only for benchmarks
|
||||
pub mod case_insensitive_memmem;
|
||||
|
||||
/// Core file picker: filesystem indexing, background watching, and fuzzy search.
|
||||
///
|
||||
@@ -139,6 +141,7 @@ pub mod types;
|
||||
/// and [`QueryTracker`].
|
||||
pub mod shared;
|
||||
|
||||
pub use bigram_filter::*;
|
||||
pub use db_healthcheck::{DbHealth, DbHealthChecker};
|
||||
pub use error::{Error, Result};
|
||||
pub use fff_query_parser::*;
|
||||
|
||||
@@ -113,7 +113,6 @@ impl SharedPicker {
|
||||
}
|
||||
}
|
||||
|
||||
// ── SharedFrecency ─────────────────────────────────────────────────────
|
||||
|
||||
/// Thread-safe shared handle to the [`FrecencyTracker`] instance.
|
||||
#[derive(Clone, Default)]
|
||||
@@ -151,7 +150,6 @@ impl SharedFrecency {
|
||||
}
|
||||
}
|
||||
|
||||
// ── SharedQueryTracker ─────────────────────────────────────────────────
|
||||
|
||||
/// Thread-safe shared handle to the [`QueryTracker`] instance.
|
||||
#[derive(Clone, Default)]
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
use crate::constraints::Constrainable;
|
||||
use crate::query_tracker::QueryMatchEntry;
|
||||
use ahash::AHashMap;
|
||||
use fff_query_parser::{FFFQuery, FuzzyQuery, Location};
|
||||
|
||||
/// Cached file contents — mmap on Unix, heap buffer on Windows.
|
||||
@@ -214,15 +213,6 @@ impl FileItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum number of distinct bigrams tracked in the inverted index.
|
||||
/// 95 printable ASCII chars (32..=126) after lowercasing → ~70 distinct → 4900 possible.
|
||||
/// We cap at 5000 to cover all printable bigrams with margin.
|
||||
/// 5000 columns × 62.5KB (500k files) = 305MB. For 50k files: 30MB.
|
||||
const MAX_BIGRAM_COLUMNS: usize = 5000;
|
||||
|
||||
/// Sentinel value: bigram has no allocated column.
|
||||
const NO_COLUMN: u32 = u32::MAX;
|
||||
|
||||
/// Page size on Apple Silicon is 16KB; on x86-64 it's 4KB.
|
||||
/// Files smaller than one page waste the remainder when mmapped.
|
||||
/// Reading them into a heap buffer avoids this overhead.
|
||||
@@ -432,524 +422,3 @@ impl Default for ContentCacheBudget {
|
||||
Self::new_for_repo(30_000)
|
||||
}
|
||||
}
|
||||
|
||||
/// Temporary dense builder for the bigram index.
|
||||
/// Uses AtomicU64 for lock-free concurrent writes during the parallel build phase.
|
||||
/// Columns are allocated lazily on first use to avoid the massive upfront allocation
|
||||
/// (previously ~300MB for 500k files, now proportional to actual bigrams found).
|
||||
/// Call `compress()` to produce the final compact `BigramIndex`.
|
||||
pub struct BigramIndexBuilder {
|
||||
lookup: Vec<AtomicU32>,
|
||||
/// Per-column bitset data, lazily allocated via OnceLock.
|
||||
col_data: Vec<OnceLock<Box<[AtomicU64]>>>,
|
||||
next_column: AtomicU32,
|
||||
words: usize,
|
||||
file_count: usize,
|
||||
populated: AtomicUsize,
|
||||
}
|
||||
|
||||
impl BigramIndexBuilder {
|
||||
pub fn new(file_count: usize) -> Self {
|
||||
let words = file_count.div_ceil(64);
|
||||
let mut lookup = Vec::with_capacity(65536);
|
||||
lookup.resize_with(65536, || AtomicU32::new(NO_COLUMN));
|
||||
let mut col_data = Vec::with_capacity(MAX_BIGRAM_COLUMNS);
|
||||
col_data.resize_with(MAX_BIGRAM_COLUMNS, OnceLock::new);
|
||||
Self {
|
||||
lookup,
|
||||
col_data,
|
||||
next_column: AtomicU32::new(0),
|
||||
words,
|
||||
file_count,
|
||||
populated: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn get_or_alloc_column(&self, key: u16) -> u32 {
|
||||
let current = self.lookup[key as usize].load(Ordering::Relaxed);
|
||||
if current != NO_COLUMN {
|
||||
return current;
|
||||
}
|
||||
let new_col = self.next_column.fetch_add(1, Ordering::Relaxed);
|
||||
if new_col >= MAX_BIGRAM_COLUMNS as u32 {
|
||||
return NO_COLUMN;
|
||||
}
|
||||
|
||||
match self.lookup[key as usize].compare_exchange(
|
||||
NO_COLUMN,
|
||||
new_col,
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => new_col,
|
||||
Err(existing) => existing,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get (or lazily allocate) the bitset for a given column index.
|
||||
#[inline]
|
||||
fn column_bitset(&self, col: u32) -> &[AtomicU64] {
|
||||
let words = self.words;
|
||||
self.col_data[col as usize].get_or_init(|| {
|
||||
let mut v = Vec::with_capacity(words);
|
||||
v.resize_with(words, || AtomicU64::new(0));
|
||||
v.into_boxed_slice()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_file_content(&self, file_idx: usize, content: &[u8]) {
|
||||
if content.len() < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
debug_assert!(file_idx < self.file_count);
|
||||
let word_idx = file_idx / 64;
|
||||
let bit_mask = 1u64 << (file_idx % 64);
|
||||
|
||||
let mut prev = content[0];
|
||||
for &b in &content[1..] {
|
||||
if (32..=126).contains(&prev) && (32..=126).contains(&b) {
|
||||
let key = (prev.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
|
||||
let col = self.get_or_alloc_column(key);
|
||||
if col != NO_COLUMN {
|
||||
self.column_bitset(col)[word_idx].fetch_or(bit_mask, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
prev = b;
|
||||
}
|
||||
self.populated.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Index skip-1 bigrams (stride 2) for a single file.
|
||||
///
|
||||
/// For content "ABCDE" this extracts pairs (A,C), (B,D), (C,E).
|
||||
/// These capture non-adjacent character relationships that are largely
|
||||
/// independent from consecutive bigrams, enabling much tighter candidate
|
||||
/// filtering when ANDead together.
|
||||
pub fn add_file_content_skip(&self, file_idx: usize, content: &[u8]) {
|
||||
if content.len() < 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
debug_assert!(file_idx < self.file_count);
|
||||
let word_idx = file_idx / 64;
|
||||
let bit_mask = 1u64 << (file_idx % 64);
|
||||
|
||||
for i in 0..content.len() - 2 {
|
||||
let a = content[i];
|
||||
let b = content[i + 2];
|
||||
if (32..=126).contains(&a) && (32..=126).contains(&b) {
|
||||
let key = (a.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
|
||||
let col = self.get_or_alloc_column(key);
|
||||
if col != NO_COLUMN {
|
||||
self.column_bitset(col)[word_idx].fetch_or(bit_mask, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.populated.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.populated.load(Ordering::Relaxed) > 0
|
||||
}
|
||||
|
||||
pub fn columns_used(&self) -> u32 {
|
||||
self.next_column
|
||||
.load(Ordering::Relaxed)
|
||||
.min(MAX_BIGRAM_COLUMNS as u32)
|
||||
}
|
||||
|
||||
/// Compress the dense builder into a compact `BigramFilter`.
|
||||
///
|
||||
/// Retains columns where the bigram appears in ≥`min_density_pct`% (or
|
||||
/// the default ~3.1% heuristic when `None`) and <90% of indexed files.
|
||||
/// Sparse columns carry too little data to justify their memory;
|
||||
/// ubiquitous columns (≥90%) are nearly all-ones and barely filter.
|
||||
///
|
||||
/// Each column's `Box<[AtomicU64]>` (~60 KB for 500k files) is freed
|
||||
/// immediately after compression via `OnceLock::take`, so peak memory
|
||||
/// during compress is roughly `max(builder, result)` instead of
|
||||
/// `builder + result`.
|
||||
pub fn compress(self, min_density_pct: Option<u32>) -> BigramFilter {
|
||||
let cols = self.columns_used() as usize;
|
||||
let words = self.words;
|
||||
let file_count = self.file_count;
|
||||
let populated = self.populated.load(Ordering::Relaxed);
|
||||
let dense_bytes = words * 8; // cost of one dense column
|
||||
|
||||
// Destructure so we can incrementally free col_data entries.
|
||||
let old_lookup = self.lookup;
|
||||
let mut col_data = self.col_data;
|
||||
|
||||
let mut lookup = vec![NO_COLUMN; 65536];
|
||||
let mut dense_data: Vec<u64> = Vec::with_capacity(cols * words);
|
||||
let mut dense_count: usize = 0;
|
||||
|
||||
for key in 0..65536u32 {
|
||||
let old_col = old_lookup[key as usize].load(Ordering::Relaxed);
|
||||
if old_col == NO_COLUMN || old_col as usize >= cols {
|
||||
continue;
|
||||
}
|
||||
let Some(bitset) = col_data[old_col as usize].take() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Count set bits to decide if this column is worth keeping.
|
||||
let mut popcount = 0u32;
|
||||
for w in 0..words {
|
||||
popcount += bitset[w].load(Ordering::Relaxed).count_ones();
|
||||
}
|
||||
|
||||
// Sparse threshold — drop bigrams appearing in too few files.
|
||||
let sparse_ok = if let Some(min_pct) = min_density_pct {
|
||||
// Percentage-based: require ≥ min_pct% of populated files.
|
||||
populated > 0 && (popcount as usize) * 100 >= populated * min_pct as usize
|
||||
} else {
|
||||
// Default heuristic: popcount ≥ words × 2 (~3.1% of files).
|
||||
(popcount as usize * 4) >= dense_bytes
|
||||
};
|
||||
if !sparse_ok {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drop ubiquitous bigrams — columns ≥90% ones carry almost no
|
||||
// filtering power and just waste memory + AND cycles.
|
||||
if populated > 0 && (popcount as usize) * 10 >= populated * 9 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dense_idx = dense_count as u32;
|
||||
lookup[key as usize] = dense_idx;
|
||||
dense_count += 1;
|
||||
|
||||
for w in 0..words {
|
||||
dense_data.push(bitset[w].load(Ordering::Relaxed));
|
||||
}
|
||||
}
|
||||
|
||||
drop(col_data);
|
||||
drop(old_lookup);
|
||||
|
||||
BigramFilter {
|
||||
lookup,
|
||||
dense_data,
|
||||
dense_count,
|
||||
words,
|
||||
file_count,
|
||||
populated,
|
||||
skip_index: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl Send for BigramIndexBuilder {}
|
||||
unsafe impl Sync for BigramIndexBuilder {}
|
||||
|
||||
/// Compressed bigram inverted index (dense-only).
|
||||
///
|
||||
/// Built from `BigramIndexBuilder::compress()`. All columns are dense bitsets
|
||||
/// packed contiguously in `dense_data` at a fixed stride of `words` — column
|
||||
/// `i` lives at `i * words`. The `lookup` table maps bigram key → column
|
||||
/// index directly, so the query path is: one lookup load → one multiply →
|
||||
/// data access (no pointer chase, no enum discriminant check, SIMD-vectorized
|
||||
/// AND).
|
||||
#[derive(Debug)]
|
||||
pub struct BigramFilter {
|
||||
lookup: Vec<u32>,
|
||||
/// Flat buffer of all dense column data laid out at fixed stride `words`.
|
||||
/// Column `i` starts at `i * words`.
|
||||
dense_data: Vec<u64>,
|
||||
dense_count: usize,
|
||||
words: usize,
|
||||
file_count: usize,
|
||||
populated: usize,
|
||||
/// Optional skip-1 bigram index (stride 2). Built from character pairs
|
||||
/// at distance 2, e.g. "ABCDE" → (A,C),(B,D),(C,E). ANDead with the
|
||||
/// consecutive bigram candidates during query to dramatically reduce
|
||||
/// false positives.
|
||||
skip_index: Option<Box<BigramFilter>>,
|
||||
}
|
||||
|
||||
/// SIMD-friendly bitwise AND of two equal-length bitsets.
|
||||
// Auto vectorized (don't touch)
|
||||
#[inline]
|
||||
fn bitset_and(result: &mut [u64], bitset: &[u64]) {
|
||||
result
|
||||
.iter_mut()
|
||||
.zip(bitset.iter())
|
||||
.for_each(|(r, b)| *r &= *b);
|
||||
}
|
||||
|
||||
impl BigramFilter {
|
||||
/// AND the posting lists for all query bigrams (consecutive + skip).
|
||||
/// Returns None if no query bigrams are tracked.
|
||||
pub fn query(&self, pattern: &[u8]) -> Option<Vec<u64>> {
|
||||
if pattern.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut result = vec![u64::MAX; self.words];
|
||||
if !self.file_count.is_multiple_of(64) {
|
||||
let last = self.words - 1;
|
||||
result[last] = (1u64 << (self.file_count % 64)) - 1;
|
||||
}
|
||||
|
||||
let words = self.words;
|
||||
let mut has_filter = false;
|
||||
|
||||
// ── Consecutive bigrams (stride 1) ─────────────────────────────
|
||||
let mut prev = pattern[0];
|
||||
for &b in &pattern[1..] {
|
||||
if (32..=126).contains(&prev) && (32..=126).contains(&b) {
|
||||
let key = (prev.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
|
||||
let col = self.lookup[key as usize];
|
||||
if col != NO_COLUMN {
|
||||
let offset = col as usize * words;
|
||||
// SAFETY: compress() guarantees offset + words <= dense_data.len()
|
||||
let slice = unsafe { self.dense_data.get_unchecked(offset..offset + words) };
|
||||
bitset_and(&mut result, slice);
|
||||
has_filter = true;
|
||||
}
|
||||
}
|
||||
prev = b;
|
||||
}
|
||||
|
||||
// ── Skip-1 bigrams (stride 2) ──────────────────────────────────
|
||||
if let Some(skip) = &self.skip_index
|
||||
&& pattern.len() >= 3
|
||||
&& let Some(skip_candidates) = skip.query_skip(pattern)
|
||||
{
|
||||
bitset_and(&mut result, &skip_candidates);
|
||||
has_filter = true;
|
||||
}
|
||||
|
||||
has_filter.then_some(result)
|
||||
}
|
||||
|
||||
/// Query using stride-2 bigrams from the pattern.
|
||||
/// For "ABCDE" queries with keys (A,C), (B,D), (C,E).
|
||||
fn query_skip(&self, pattern: &[u8]) -> Option<Vec<u64>> {
|
||||
let mut result = vec![u64::MAX; self.words];
|
||||
if !self.file_count.is_multiple_of(64) {
|
||||
let last = self.words - 1;
|
||||
result[last] = (1u64 << (self.file_count % 64)) - 1;
|
||||
}
|
||||
|
||||
let words = self.words;
|
||||
let mut has_filter = false;
|
||||
|
||||
for i in 0..pattern.len().saturating_sub(2) {
|
||||
let a = pattern[i];
|
||||
let b = pattern[i + 2];
|
||||
if (32..=126).contains(&a) && (32..=126).contains(&b) {
|
||||
let key = (a.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
|
||||
let col = self.lookup[key as usize];
|
||||
if col != NO_COLUMN {
|
||||
let offset = col as usize * words;
|
||||
let slice = unsafe { self.dense_data.get_unchecked(offset..offset + words) };
|
||||
bitset_and(&mut result, slice);
|
||||
has_filter = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
has_filter.then_some(result)
|
||||
}
|
||||
|
||||
/// Attach a skip-1 bigram index for tighter candidate filtering.
|
||||
pub fn set_skip_index(&mut self, skip: BigramFilter) {
|
||||
self.skip_index = Some(Box::new(skip));
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_candidate(candidates: &[u64], file_idx: usize) -> bool {
|
||||
let word = file_idx / 64;
|
||||
let bit = file_idx % 64;
|
||||
word < candidates.len() && candidates[word] & (1u64 << bit) != 0
|
||||
}
|
||||
|
||||
pub fn count_candidates(candidates: &[u64]) -> usize {
|
||||
candidates.iter().map(|w| w.count_ones() as usize).sum()
|
||||
}
|
||||
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.populated > 0
|
||||
}
|
||||
|
||||
pub fn file_count(&self) -> usize {
|
||||
self.file_count
|
||||
}
|
||||
|
||||
pub fn columns_used(&self) -> usize {
|
||||
self.dense_count
|
||||
}
|
||||
|
||||
/// Total heap bytes used by this index (lookup + dense data + skip).
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
let lookup_bytes = self.lookup.len() * std::mem::size_of::<u32>();
|
||||
let dense_bytes = self.dense_data.len() * std::mem::size_of::<u64>();
|
||||
let skip_bytes = self.skip_index.as_ref().map_or(0, |s| s.heap_bytes());
|
||||
lookup_bytes + dense_bytes + skip_bytes
|
||||
}
|
||||
|
||||
/// Check whether a bigram key is present in this index.
|
||||
pub fn has_key(&self, key: u16) -> bool {
|
||||
self.lookup[key as usize] != NO_COLUMN
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract deduplicated bigram keys from file content.
|
||||
/// Same logic as `BigramIndexBuilder::add_file_content`: consecutive printable
|
||||
/// ASCII pairs, lowercased, encoded as `(prev << 8) | cur`.
|
||||
pub fn extract_bigrams(content: &[u8]) -> Vec<u16> {
|
||||
if content.len() < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
// Use a flat bitset (65536 bits = 8 KB) for dedup — faster than HashSet.
|
||||
let mut seen = vec![0u64; 1024]; // 1024 * 64 = 65536 bits
|
||||
let mut bigrams = Vec::new();
|
||||
|
||||
let mut prev = content[0];
|
||||
for &b in &content[1..] {
|
||||
if (32..=126).contains(&prev) && (32..=126).contains(&b) {
|
||||
let key = (prev.to_ascii_lowercase() as u16) << 8 | b.to_ascii_lowercase() as u16;
|
||||
let word = key as usize / 64;
|
||||
let bit = 1u64 << (key as usize % 64);
|
||||
if seen[word] & bit == 0 {
|
||||
seen[word] |= bit;
|
||||
bigrams.push(key);
|
||||
}
|
||||
}
|
||||
prev = b;
|
||||
}
|
||||
bigrams
|
||||
}
|
||||
|
||||
/// Tracks bigram changes since the base `BigramFilter` was built.
|
||||
///
|
||||
/// Modified and added files store their own bigram sets. Deleted files are
|
||||
/// tombstoned in a bitset so they can be excluded from base query results.
|
||||
/// This overlay is updated by the background watcher on every file event
|
||||
/// and cleared when the base index is rebuilt.
|
||||
#[derive(Debug)]
|
||||
pub struct BigramOverlay {
|
||||
/// Per-file bigram sets for files modified since the base was built.
|
||||
/// Key = file index in the base `Vec<FileItem>`.
|
||||
modified: AHashMap<usize, Vec<u16>>,
|
||||
|
||||
/// Tombstone bitset — one bit per base file. Set bits are excluded
|
||||
/// from base query results.
|
||||
tombstones: Vec<u64>,
|
||||
|
||||
/// Bigram sets for files added after the base was built (overflow files).
|
||||
added: Vec<Vec<u16>>,
|
||||
|
||||
/// Number of base files this overlay was created for.
|
||||
base_file_count: usize,
|
||||
}
|
||||
|
||||
impl BigramOverlay {
|
||||
pub fn new(base_file_count: usize) -> Self {
|
||||
let words = base_file_count.div_ceil(64);
|
||||
Self {
|
||||
modified: AHashMap::new(),
|
||||
tombstones: vec![0u64; words],
|
||||
added: Vec::new(),
|
||||
base_file_count,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record updated bigram data for a modified base file.
|
||||
pub fn modify_file(&mut self, file_idx: usize, content: &[u8]) {
|
||||
self.modified.insert(file_idx, extract_bigrams(content));
|
||||
}
|
||||
|
||||
/// Tombstone a deleted base file.
|
||||
pub fn delete_file(&mut self, file_idx: usize) {
|
||||
if file_idx < self.base_file_count {
|
||||
let word = file_idx / 64;
|
||||
self.tombstones[word] |= 1u64 << (file_idx % 64);
|
||||
}
|
||||
self.modified.remove(&file_idx);
|
||||
}
|
||||
|
||||
/// Record bigrams for a newly added (overflow) file.
|
||||
pub fn add_file(&mut self, content: &[u8]) {
|
||||
self.added.push(extract_bigrams(content));
|
||||
}
|
||||
|
||||
/// Return base file indices of modified files whose bigrams match ALL
|
||||
/// of the given `pattern_bigrams`.
|
||||
pub fn query_modified(&self, pattern_bigrams: &[u16]) -> Vec<usize> {
|
||||
if pattern_bigrams.is_empty() {
|
||||
return self.modified.keys().copied().collect();
|
||||
}
|
||||
self.modified
|
||||
.iter()
|
||||
.filter_map(|(&file_idx, bigrams)| {
|
||||
pattern_bigrams
|
||||
.iter()
|
||||
.all(|pb| bigrams.contains(pb))
|
||||
.then_some(file_idx)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return overflow indices (into the `added` vec) whose bigrams match
|
||||
/// ALL of the given `pattern_bigrams`.
|
||||
pub fn query_added(&self, pattern_bigrams: &[u16]) -> Vec<usize> {
|
||||
if pattern_bigrams.is_empty() {
|
||||
return (0..self.added.len()).collect();
|
||||
}
|
||||
self.added
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, bigrams)| {
|
||||
pattern_bigrams
|
||||
.iter()
|
||||
.all(|pb| bigrams.contains(pb))
|
||||
.then_some(idx)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the tombstone bitset for clearing base candidates.
|
||||
pub fn tombstones(&self) -> &[u64] {
|
||||
&self.tombstones
|
||||
}
|
||||
|
||||
pub fn is_tombstoned(&self, file_idx: usize) -> bool {
|
||||
let word = file_idx / 64;
|
||||
word < self.tombstones.len() && self.tombstones[word] & (1u64 << (file_idx % 64)) != 0
|
||||
}
|
||||
|
||||
pub fn base_file_count(&self) -> usize {
|
||||
self.base_file_count
|
||||
}
|
||||
|
||||
/// Remove an overflow entry by index (when the file is deleted).
|
||||
pub fn remove_added(&mut self, idx: usize) {
|
||||
if idx < self.added.len() {
|
||||
self.added.remove(idx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update an existing overflow entry's bigrams.
|
||||
pub fn update_added(&mut self, idx: usize, bigrams: Vec<u16>) {
|
||||
if idx < self.added.len() {
|
||||
self.added[idx] = bigrams;
|
||||
}
|
||||
}
|
||||
|
||||
/// Total number of entries tracked (for deciding when to trigger a full rebuild).
|
||||
pub fn overlay_size(&self) -> usize {
|
||||
self.modified.len()
|
||||
+ self.added.len()
|
||||
+ self
|
||||
.tombstones
|
||||
.iter()
|
||||
.map(|w| w.count_ones() as usize)
|
||||
.sum::<usize>()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ fn modified_file_findable_via_overlay() {
|
||||
.unwrap();
|
||||
fs::write(base.join("gamma.txt"), "yet another file\nmore lines\n").unwrap();
|
||||
|
||||
// ── Phase 1: Initialize picker ──────────────────────────────────────
|
||||
let shared_picker = SharedPicker::default();
|
||||
let shared_frecency = SharedFrecency::default();
|
||||
|
||||
@@ -80,7 +79,6 @@ fn modified_file_findable_via_overlay() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Phase 2: Grep BEFORE modification ───────────────────────────────
|
||||
// "UNIQUE_NEEDLE" should NOT exist in any file yet.
|
||||
{
|
||||
let guard = shared_picker.read().unwrap();
|
||||
@@ -95,7 +93,6 @@ fn modified_file_findable_via_overlay() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Phase 3: Modify a file on disk ──────────────────────────────────
|
||||
// Sleep so the filesystem mtime (seconds granularity) advances past the
|
||||
// value recorded during scan. Without this, on_create_or_modify skips
|
||||
// mmap invalidation and grep reads stale cached content.
|
||||
@@ -121,7 +118,6 @@ fn modified_file_findable_via_overlay() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Phase 4: Grep AFTER modification — WITH overlay ─────────────────
|
||||
// The bigram index was built BEFORE the modification, so without the
|
||||
// overlay, beta.txt would be filtered out (its old bigrams don't contain
|
||||
// "UNIQUE_NEEDLE"). The overlay should fix that.
|
||||
@@ -139,7 +135,6 @@ fn modified_file_findable_via_overlay() {
|
||||
assert!(result.matches[0].line_content.contains("UNIQUE_NEEDLE"));
|
||||
}
|
||||
|
||||
// ── Phase 5: Grep AFTER modification — WITHOUT overlay ──────────────
|
||||
// Prove the overlay is actually doing something: without it, the bigram
|
||||
// index would filter out beta.txt and the search would miss the needle.
|
||||
{
|
||||
|
||||
@@ -158,7 +158,6 @@ fn main() {
|
||||
eprintln!("Needle: {:?}", std::str::from_utf8(&needle_lower).unwrap());
|
||||
eprintln!("Iters: {}", iters);
|
||||
|
||||
// ── Load all file contents into memory ─────────────────────────────
|
||||
eprint!("\n[1/2] Loading files into memory... ");
|
||||
let t = Instant::now();
|
||||
let contents = load_file_contents(&canonical);
|
||||
@@ -170,11 +169,10 @@ fn main() {
|
||||
t.elapsed().as_secs_f64()
|
||||
);
|
||||
|
||||
// ── Benchmark ──────────────────────────────────────────────────────
|
||||
eprintln!("\n[2/2] Benchmarking memmem prefilter (scanning ALL files)");
|
||||
|
||||
bench_impl(
|
||||
"Packed pair (AVX2 two-byte scan)",
|
||||
"Packed pair: (AVX2 two-byte scan)",
|
||||
&contents,
|
||||
&needle_lower,
|
||||
total_bytes,
|
||||
@@ -183,20 +181,11 @@ fn main() {
|
||||
);
|
||||
|
||||
bench_impl(
|
||||
"memchr2 first-byte + AVX2 verify",
|
||||
"scalar: memchr2 first-byte + AVX2 verify",
|
||||
&contents,
|
||||
&needle_lower,
|
||||
total_bytes,
|
||||
iters,
|
||||
case_insensitive_memmem::search,
|
||||
);
|
||||
|
||||
bench_impl(
|
||||
"memchr2 first-byte + scalar verify",
|
||||
&contents,
|
||||
&needle_lower,
|
||||
total_bytes,
|
||||
iters,
|
||||
case_insensitive_memmem::search_scalar,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
/// cargo build --release --bin bench_grep_query
|
||||
/// ./target/release/bench_grep_query --path ~/dev/chromium --query "MAX_FILE_SIZE" --iters 3
|
||||
use fff::FileItem;
|
||||
use fff::BigramIndexBuilder;
|
||||
use fff::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
|
||||
use fff::types::{BigramIndexBuilder, ContentCacheBudget};
|
||||
use fff::types::ContentCacheBudget;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
@@ -76,7 +77,7 @@ fn fmt_dur(us: u128) -> String {
|
||||
|
||||
fn run_grep(
|
||||
files: &[FileItem],
|
||||
index: Option<&fff::types::BigramFilter>,
|
||||
index: Option<&fff::BigramFilter>,
|
||||
query: &str,
|
||||
iters: usize,
|
||||
) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use fff::FileItem;
|
||||
/// Live grep benchmark profiler for fff.nvim
|
||||
///
|
||||
/// Benchmarks the full grep pipeline against a large repository (Linux kernel).
|
||||
@@ -10,8 +9,11 @@ use fff::FileItem;
|
||||
/// Usage:
|
||||
/// cargo build --release --bin grep_profiler
|
||||
/// ./target/release/grep_profiler [--path /path/to/repo]
|
||||
use fff::grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query};
|
||||
use fff::types::{BigramFilter, BigramIndexBuilder, ContentCacheBudget};
|
||||
use fff::{
|
||||
BigramFilter, BigramIndexBuilder, FileItem,
|
||||
grep::{GrepMode, GrepSearchOptions, grep_search, parse_grep_query},
|
||||
types::ContentCacheBudget,
|
||||
};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -834,7 +834,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── AI grep config tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_ai_grep_detects_file_path() {
|
||||
@@ -1035,7 +1034,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── File picker filename constraint tests ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_file_picker_bare_filename_constraint() {
|
||||
|
||||
@@ -217,13 +217,11 @@ describe("fff-node", { concurrency: 1 }, () => {
|
||||
assert.ok(r.value > 0);
|
||||
});
|
||||
|
||||
// ── Scan ────────────────────────────────────────────────────────────
|
||||
|
||||
it("isScanning returns a boolean", () => {
|
||||
assert.equal(typeof finder.isScanning(), "boolean");
|
||||
});
|
||||
|
||||
// ── Health check ────────────────────────────────────────────────────
|
||||
|
||||
describe("healthCheck", { concurrency: 1 }, () => {
|
||||
it("reports initialized state with instance", () => {
|
||||
|
||||
Reference in New Issue
Block a user