Compare commits

...

1 Commits

Author SHA1 Message Date
Dmitriy Kovalenko 82541da476 perf: -256kb RAM consumption for persistent index 2026-04-02 14:24:04 -07:00
14 changed files with 536 additions and 603 deletions
+9 -2
View File
@@ -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(|| {
+497
View File
@@ -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 {
+3 -5
View File
@@ -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]);
+12 -6
View File
@@ -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;
+4 -1
View File
@@ -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::*;
-2
View File
@@ -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 -532
View File
@@ -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.
{
+2 -13
View File
@@ -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,
);
}
+3 -2
View File
@@ -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,
) {
+5 -3
View File
@@ -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};
-2
View File
@@ -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() {
-2
View File
@@ -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", () => {