Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2175af1bf2 | |||
| 6bb27e7a58 |
@@ -2,7 +2,7 @@
|
||||
"mcpServers": {
|
||||
"fff": {
|
||||
"type": "stdio",
|
||||
"command": "/Users/neogoose/dev/fff.nvim/target/release/fff-mcp",
|
||||
"command": "./target/release/fff-mcp",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicU16, AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
use ahash::AHashMap;
|
||||
@@ -13,13 +12,13 @@ const MAX_BIGRAM_COLUMNS: usize = 5000;
|
||||
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.
|
||||
/// Builds from the many threads reading file contents in parallel
|
||||
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]>>>,
|
||||
col_data: Vec<AtomicU64>,
|
||||
next_column: AtomicU16,
|
||||
words: usize,
|
||||
file_count: usize,
|
||||
@@ -31,8 +30,8 @@ impl BigramIndexBuilder {
|
||||
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);
|
||||
let mut col_data = Vec::with_capacity(MAX_BIGRAM_COLUMNS * words);
|
||||
col_data.resize_with(MAX_BIGRAM_COLUMNS * words, || AtomicU64::new(0));
|
||||
Self {
|
||||
lookup,
|
||||
col_data,
|
||||
@@ -67,12 +66,8 @@ impl BigramIndexBuilder {
|
||||
|
||||
#[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()
|
||||
})
|
||||
let start = col as usize * self.words;
|
||||
&self.col_data[start..start + self.words]
|
||||
}
|
||||
|
||||
pub(crate) fn add_file_content(&self, skip_builder: &Self, file_idx: usize, content: &[u8]) {
|
||||
@@ -84,8 +79,8 @@ impl BigramIndexBuilder {
|
||||
let word_idx = file_idx / 64;
|
||||
let bit_mask = 1u64 << (file_idx % 64);
|
||||
|
||||
// Stack-local dedup bitsets: 1024 × u64 = 8 KB each, covers all 65536
|
||||
// possible bigram keys. Fits comfortably in L1 cache.
|
||||
// Stack-local dedup bitsets: 1024 × u64 = 8 KB each, covers all 65536 bigrams with margin
|
||||
// have to fit in L1 cache
|
||||
let mut seen_consec = [0u64; 1024];
|
||||
let mut seen_skip = [0u64; 1024];
|
||||
|
||||
@@ -105,7 +100,7 @@ impl BigramIndexBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// Main loop: consecutive (i-1, i) and skip-1 (i-2, i) in one pass.
|
||||
// Main loop: consecutive (i-1, i) and skip-1 (i-2, i)
|
||||
for i in 2..len {
|
||||
let cur = bytes[i];
|
||||
|
||||
@@ -169,9 +164,8 @@ impl BigramIndexBuilder {
|
||||
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 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);
|
||||
@@ -182,16 +176,14 @@ impl BigramIndexBuilder {
|
||||
if old_col == NO_COLUMN || old_col as usize >= cols {
|
||||
continue;
|
||||
}
|
||||
// by taking this we drop the bitset in the end of the loop effectively
|
||||
// controlling the memory consumtpion, so we do not grow another bitset during the loop
|
||||
let Some(bitset) = col_data[old_col as usize].take() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let col_start = old_col as usize * words;
|
||||
let bitset = &col_data[col_start..col_start + words];
|
||||
|
||||
// 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();
|
||||
for column in bitset.iter().take(words) {
|
||||
popcount += column.load(Ordering::Relaxed).count_ones();
|
||||
}
|
||||
|
||||
// drop bigrams appearing in too few files
|
||||
@@ -217,13 +209,13 @@ impl BigramIndexBuilder {
|
||||
lookup[key] = dense_idx;
|
||||
dense_count += 1;
|
||||
|
||||
for w in 0..words {
|
||||
dense_data.push(bitset[w].load(Ordering::Relaxed));
|
||||
for column in bitset.iter().take(words) {
|
||||
dense_data.push(column.load(Ordering::Relaxed));
|
||||
}
|
||||
}
|
||||
|
||||
drop(col_data);
|
||||
drop(old_lookup);
|
||||
// col_data + old_lookup dropped here — single deallocation each,
|
||||
// no fragmentation.
|
||||
|
||||
BigramFilter {
|
||||
lookup,
|
||||
|
||||
@@ -25,6 +25,8 @@ pub enum Error {
|
||||
EnvOpen(#[source] heed::Error),
|
||||
#[error("Failed to create frecency database: {0}")]
|
||||
DbCreate(#[source] heed::Error),
|
||||
#[error("Failed to open frecency database: {0}")]
|
||||
DbOpen(#[source] heed::Error),
|
||||
#[error("Failed to clear stale readers for frecency database: {0}")]
|
||||
DbClearStaleReaders(#[source] heed::Error),
|
||||
|
||||
|
||||
@@ -102,9 +102,10 @@ pub struct FuzzySearchOptions<'a> {
|
||||
#[derive(Debug, Clone)]
|
||||
struct FileSync {
|
||||
/// All files: `files[..base_count]` are sorted by path (base index, used
|
||||
/// for binary search and bigram); `files[base_count..]` are overflow files
|
||||
/// added since the last full reindex. Deletions in the base use tombstones
|
||||
/// (`is_deleted = true`) to keep bigram indices stable.
|
||||
/// for binary search and bigram);
|
||||
///
|
||||
/// `files[base_count..]` are overflow files added since the last full reindex.
|
||||
/// Deletions in the base use tombstones (`is_deleted = true`) to keep bigram indices stable.
|
||||
files: Vec<FileItem>,
|
||||
/// Number of base files (the sorted prefix used for binary search / bigram).
|
||||
base_count: usize,
|
||||
@@ -152,6 +153,8 @@ impl FileSync {
|
||||
|
||||
/// Find a file in the overflow portion by path (linear scan).
|
||||
/// Returns the absolute index into `files`.
|
||||
///
|
||||
/// the overflowed items are not ordered so we can not use binary search
|
||||
fn find_overflow_index(&self, path: &Path) -> Option<usize> {
|
||||
self.files[self.base_count..]
|
||||
.iter()
|
||||
@@ -1307,8 +1310,9 @@ pub fn build_bigram_index(
|
||||
let skip_index = skip_builder.compress(Some(12));
|
||||
index.set_skip_index(skip_index);
|
||||
|
||||
// The builder just freed ~276 MB (for 500k files) of atomic bitsets.
|
||||
// Hint the allocator to return those pages to the OS.
|
||||
// The builders' flat buffers were freed by compress() above (single
|
||||
// deallocation each). Hint the allocator to return pages from other
|
||||
// per-thread allocations (file reads, sort buffers) during the build.
|
||||
hint_allocator_collect();
|
||||
|
||||
info!(
|
||||
@@ -1650,9 +1654,11 @@ pub(crate) fn detect_binary_content(content: &[u8]) -> bool {
|
||||
fn hint_allocator_collect() {
|
||||
#[cfg(feature = "mimalloc-collect")]
|
||||
{
|
||||
// Collect every rayon worker thread's mimalloc heap — the bigram
|
||||
// builder allocated across all of them.
|
||||
rayon::broadcast(|_| unsafe { libmimalloc_sys::mi_collect(true) });
|
||||
// Collect BACKGROUND_THREAD_POOL workers — that's where the bigram
|
||||
// builder allocated memory. `rayon::broadcast` would target the global
|
||||
// pool, which is the wrong set of threads.
|
||||
BACKGROUND_THREAD_POOL.broadcast(|_| unsafe { libmimalloc_sys::mi_collect(true) });
|
||||
|
||||
// Main thread too.
|
||||
unsafe { libmimalloc_sys::mi_collect(true) };
|
||||
}
|
||||
|
||||
@@ -73,11 +73,28 @@ impl FrecencyTracker {
|
||||
env.clear_stale_readers()
|
||||
.map_err(Error::DbClearStaleReaders)?;
|
||||
|
||||
// we will open the default unnamed database
|
||||
let mut wtxn = env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
let db = env
|
||||
.create_database(&mut wtxn, None)
|
||||
.map_err(Error::DbCreate)?;
|
||||
// Try read-only open first — avoids blocking on the LMDB write lock
|
||||
// when another process (Neovim, another fff-mcp) already has it.
|
||||
// Only fall back to create_database (which needs a write txn) if the
|
||||
// database doesn't exist yet.
|
||||
let rtxn = env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
let maybe_db: Option<Database<Bytes, SerdeBincode<VecDeque<u64>>>> =
|
||||
env.open_database(&rtxn, None).map_err(Error::DbOpen)?;
|
||||
|
||||
drop(rtxn);
|
||||
|
||||
let db = match maybe_db {
|
||||
Some(db) => db,
|
||||
None => {
|
||||
// First time: create the database (requires write lock).
|
||||
let mut wtxn = env.write_txn().map_err(Error::DbStartWriteTxn)?;
|
||||
let db = env
|
||||
.create_database(&mut wtxn, None)
|
||||
.map_err(Error::DbCreate)?;
|
||||
wtxn.commit().map_err(Error::DbCommit)?;
|
||||
db
|
||||
}
|
||||
};
|
||||
|
||||
Ok(FrecencyTracker {
|
||||
db,
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 April 06
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 April 07
|
||||
|
||||
==============================================================================
|
||||
Table of Contents *fff.nvim-table-of-contents*
|
||||
|
||||
Reference in New Issue
Block a user