feat: Significant performance improvements up to +190% (#182)

This commit is contained in:
Dmitriy Kovalenko
2025-12-05 23:44:14 -08:00
committed by GitHub
parent 65aeacf9e2
commit 9edf195c8f
14 changed files with 1604 additions and 113 deletions
+3
View File
@@ -10,3 +10,6 @@ result
.repro/
.wrangler/
*.so
big-repo/
# all the perf like utility files
*.data
-1
View File
@@ -1,4 +1,3 @@
syntax = "LuaJIT"
column_width = 120
line_endings = "Unix"
indent_type = "Spaces"
Generated
+247 -5
View File
@@ -26,6 +26,18 @@ dependencies = [
"libc",
]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -103,6 +115,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.27"
@@ -141,6 +159,58 @@ dependencies = [
"windows-link",
]
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "clap"
version = "4.5.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.5.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
dependencies = [
"anstyle",
"clap_lex",
]
[[package]]
name = "clap_lex"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "constant_time_eq"
version = "0.3.1"
@@ -153,6 +223,42 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "criterion"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
dependencies = [
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"is-terminal",
"itertools",
"num-traits",
"once_cell",
"oorandom",
"plotters",
"rayon",
"regex",
"serde",
"serde_derive",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
"itertools",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
@@ -196,6 +302,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "ctrlc"
version = "3.4.7"
@@ -263,25 +375,37 @@ version = "0.1.0"
dependencies = [
"blake3",
"chrono",
"criterion",
"ctrlc",
"git2",
"glidesort",
"heed",
"ignore",
"mimalloc",
"mlua",
"neo_frizbee",
"notify",
"notify-debouncer-mini",
"notify-debouncer-full",
"once_cell",
"openssl",
"pathdiff",
"rayon",
"tempfile",
"thiserror 2.0.12",
"tracing",
"tracing-appender",
"tracing-subscriber",
]
[[package]]
name = "file-id"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "foreign-types"
version = "0.3.2"
@@ -342,6 +466,12 @@ dependencies = [
"url",
]
[[package]]
name = "glidesort"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2e102e6eb644d3e0b186fc161e4460417880a0a0b87d235f2e5b8fb30f2e9e0"
[[package]]
name = "globset"
version = "0.4.16"
@@ -355,6 +485,17 @@ dependencies = [
"regex-syntax 0.8.5",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "heed"
version = "0.22.0"
@@ -393,6 +534,12 @@ dependencies = [
"serde_json",
]
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "iana-time-zone"
version = "0.1.63"
@@ -560,6 +707,26 @@ dependencies = [
"libc",
]
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.60.2",
]
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.15"
@@ -844,15 +1011,16 @@ dependencies = [
]
[[package]]
name = "notify-debouncer-mini"
version = "0.7.0"
name = "notify-debouncer-full"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17849edfaabd9a5fef1c606d99cfc615a8e99f7ac4366406d86c7942a3184cf2"
checksum = "375bd3a138be7bfeff3480e4a623df4cbfb55b79df617c055cd810ba466fa078"
dependencies = [
"file-id",
"log",
"notify",
"notify-types",
"tempfile",
"walkdir",
]
[[package]]
@@ -892,6 +1060,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "openssl"
version = "0.10.73"
@@ -1051,6 +1225,34 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "potential_utf"
version = "0.1.2"
@@ -1429,6 +1631,16 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "tracing"
version = "0.1.41"
@@ -1620,6 +1832,16 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "winapi"
version = "0.3.9"
@@ -1896,6 +2118,26 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zerofrom"
version = "0.1.6"
+17 -1
View File
@@ -14,6 +14,13 @@ path = "src/bin/test_watcher.rs"
[[bin]]
name = "jemalloc_profile"
path = "src/bin/jemalloc_profile.rs"
[[bin]]
name = "search_profiler"
path = "src/bin/search_profiler.rs"
[[bin]]
name = "bench_search_only"
path = "src/bin/bench_search_only.rs"
[dependencies]
@@ -21,6 +28,7 @@ blake3 = "1.8.2"
chrono = { version = "0.4", features = ["serde"] }
ctrlc = "3.4.2"
git2 = "0.20.2"
glidesort = "0.1"
heed = "0.22.0"
ignore = "0.4.22"
mimalloc = "0.1.47"
@@ -28,7 +36,7 @@ mimalloc = "0.1.47"
mlua = { version = "0.11.1", features = ["module", "luajit"] }
neo_frizbee = { version = "0.7.1" }
notify = "8.1.0"
notify-debouncer-mini = "0.7"
notify-debouncer-full = "0.6"
once_cell = "1.20.2"
openssl = { version = "0.10", features = ["vendored"] }
pathdiff = "0.2.1"
@@ -37,3 +45,11 @@ thiserror = "2.0.10"
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
tempfile = "3.8"
[[bench]]
name = "indexing_and_search"
harness = false
+485
View File
@@ -0,0 +1,485 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_nvim::FILE_PICKER;
use fff_nvim::file_picker::FilePicker;
use std::path::PathBuf;
use std::time::Duration;
use tracing_subscriber;
/// Initialize tracing to output to console
fn init_tracing() {
// use tracing_subscriber::EnvFilter;
// use tracing_subscriber::fmt;
// let _ = fmt()
// .with_env_filter(
// EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
// )
// .with_target(false)
// .with_thread_ids(true)
// .with_line_number(true)
// .try_init();
}
/// Initialize FilePicker and insert into global state
fn init_file_picker_internal(path: &str) -> Result<(), String> {
let picker = FilePicker::new(path.to_string())
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))?;
let mut picker_guard = FILE_PICKER
.write()
.map_err(|_| "Failed to acquire write lock")?;
*picker_guard = Some(picker);
Ok(())
}
/// Helper function to wait for scanning to complete and get file count
fn wait_for_scan_completion(timeout_secs: u64) -> Result<usize, String> {
let start = std::time::Instant::now();
let timeout = Duration::from_secs(timeout_secs);
let mut last_log = std::time::Instant::now();
let mut iteration = 0;
loop {
iteration += 1;
{
let picker_guard = FILE_PICKER
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
let is_scanning = picker.is_scan_active();
let file_count = picker.get_files().len();
// Log progress every 2 seconds
if last_log.elapsed() >= Duration::from_secs(2) {
eprintln!(
" [{:.1}s] Scanning: {}, Files: {}, Iterations: {}",
start.elapsed().as_secs_f32(),
is_scanning,
file_count,
iteration
);
last_log = std::time::Instant::now();
}
if !is_scanning && file_count > 0 {
eprintln!(
" ✓ Scan complete after {:.2}s: {} files found",
start.elapsed().as_secs_f32(),
file_count
);
return Ok(file_count);
}
} else {
if iteration % 100 == 0 {
eprintln!(
" [{:.1}s] FilePicker is None (iteration {})",
start.elapsed().as_secs_f32(),
iteration
);
}
}
}
if start.elapsed() > timeout {
return Err(format!(
"Scan timed out after {} seconds (iteration {})",
timeout_secs, iteration
));
}
std::thread::sleep(Duration::from_millis(100));
}
}
/// Get files from the global FILE_PICKER
fn get_files_snapshot() -> Result<Vec<fff_nvim::types::FileItem>, String> {
let picker_guard = FILE_PICKER
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
Ok(picker.get_files().to_vec())
} else {
Err("FilePicker not initialized".to_string())
}
}
/// Clean up global state
fn cleanup_global_state() {
if let Ok(mut picker_guard) = FILE_PICKER.write() {
if let Some(mut picker) = picker_guard.take() {
picker.stop_background_monitor();
}
}
}
/// Initialize FilePicker once and return files snapshot
fn setup_once() -> Result<Vec<fff_nvim::types::FileItem>, String> {
init_tracing();
let big_repo_path = PathBuf::from("./big-repo");
if !big_repo_path.exists() {
return Err("./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo".to_string());
}
let canonical_path = big_repo_path
.canonicalize()
.map_err(|e| format!("Failed to canonicalize path: {}", e))?;
eprintln!(" Path: {:?}", canonical_path);
{
let picker_guard = FILE_PICKER
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
let files = picker.get_files();
if !files.is_empty() {
eprintln!(" Reusing existing index with {} files", files.len());
return Ok(files.to_vec());
}
}
}
cleanup_global_state();
std::thread::sleep(Duration::from_millis(500));
init_file_picker_internal(&canonical_path.to_string_lossy())?;
eprintln!(" Waiting for background scan to complete...");
let file_count = wait_for_scan_completion(120)?;
eprintln!(
" ✓ Indexed {} files (will be reused for all benchmarks)\n",
file_count
);
get_files_snapshot()
}
/// Benchmark for indexing the big-repo directory
fn bench_indexing(c: &mut Criterion) {
init_tracing();
let big_repo_path = PathBuf::from("./big-repo");
if !big_repo_path.exists() {
eprintln!(
"./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo"
);
return;
}
let canonical_path = match big_repo_path.canonicalize() {
Ok(p) => p,
Err(e) => {
eprintln!("⚠ Failed to canonicalize path: {}", e);
return;
}
};
let mut group = c.benchmark_group("indexing");
group.sample_size(10);
group.measurement_time(Duration::from_secs(20));
group.bench_function("index_big_repo", |b| {
b.iter(|| {
cleanup_global_state();
std::thread::sleep(Duration::from_millis(500));
let start = std::time::Instant::now();
init_file_picker_internal(black_box(&canonical_path.to_string_lossy()))
.expect("Failed to init FilePicker");
match wait_for_scan_completion(120) {
Ok(file_count) => {
let elapsed = start.elapsed();
eprintln!(" ✓ Indexed {} files in {:?}", file_count, elapsed);
file_count
}
Err(e) => {
eprintln!(" ✗ Error: {}", e);
0
}
}
});
});
group.finish();
}
/// Benchmark for searching with various query patterns
fn bench_search_queries(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
Err(e) => {
eprint!("Failed to setup picker {e:?}");
return;
}
};
let mut group = c.benchmark_group("search");
group.sample_size(100);
let test_queries = vec![
("short", "mod"),
("medium", "controller"),
("long", "user_authentication"),
("typo", "contrlr"),
("partial", "src/lib"),
];
for (name, query) in test_queries {
group.bench_with_input(BenchmarkId::new("query", name), &query, |b, &query| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
black_box(100),
black_box(4),
black_box(None),
black_box(false),
);
results.total_matched
});
});
}
group.finish();
}
/// Benchmark search with different thread counts
fn bench_search_thread_scaling(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
Err(e) => {
eprintln!("⚠ Skipping thread scaling benchmarks: {}", e);
return;
}
};
let mut group = c.benchmark_group("thread_scaling");
group.sample_size(100);
let query = "controller";
let thread_counts = vec![1, 2, 4, 8];
for threads in thread_counts {
group.bench_with_input(
BenchmarkId::from_parameter(threads),
&threads,
|b, &threads| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
black_box(100),
black_box(threads),
black_box(None),
black_box(false),
);
results.total_matched
});
},
);
}
group.finish();
}
/// Benchmark search with different result limits
fn bench_search_result_limits(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
Err(e) => {
eprintln!("⚠ Skipping result limit benchmarks: {}", e);
return;
}
};
let mut group = c.benchmark_group("result_limits");
group.sample_size(100);
let query = "mod";
let result_limits = vec![10, 50, 100, 500];
for limit in result_limits {
group.bench_with_input(BenchmarkId::from_parameter(limit), &limit, |b, &limit| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
black_box(limit),
black_box(4),
black_box(None),
black_box(false),
);
results.total_matched
});
});
}
group.finish();
}
/// Benchmark search algorithm performance scaling with file count
fn bench_search_scalability(c: &mut Criterion) {
let all_files = match setup_once() {
Ok(files) => files,
Err(e) => {
eprintln!("⚠ Skipping scalability benchmarks: {}", e);
return;
}
};
if all_files.len() < 1000 {
eprintln!(
"⚠ Skipping scalability benchmark: need at least 1000 files, got {}",
all_files.len()
);
return;
}
let mut group = c.benchmark_group("search_scalability");
group.sample_size(50);
let query = "controller";
let file_counts = vec![100, 1000, 5000, 10000, all_files.len().min(50000)];
for count in file_counts {
if count > all_files.len() {
continue;
}
let subset = &all_files[..count];
group.bench_with_input(BenchmarkId::from_parameter(count), &count, |b, _| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(subset),
black_box(query),
black_box(100),
black_box(4),
black_box(None),
black_box(false),
);
results.total_matched
});
});
}
group.finish();
}
/// Benchmark search performance with different ordering modes
fn bench_search_ordering(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
Err(e) => {
eprintln!("⚠ Skipping ordering benchmarks: {}", e);
return;
}
};
let mut group = c.benchmark_group("ordering");
group.sample_size(100);
let query = "controller";
// Benchmark normal order (descending)
group.bench_function("normal_order", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
black_box(100),
black_box(4),
black_box(None),
black_box(false),
);
results.total_matched
});
});
// Benchmark reverse order (ascending)
group.bench_function("reverse_order", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
black_box(100),
black_box(4),
black_box(None),
black_box(true),
);
results.total_matched
});
});
// Benchmark with large result set
group.bench_function("normal_order_large", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("mod"),
black_box(500),
black_box(4),
black_box(None),
black_box(false),
);
results.total_matched
});
});
group.bench_function("reverse_order_large", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("mod"),
black_box(500),
black_box(4),
black_box(None),
black_box(true),
);
results.total_matched
});
});
// Benchmark with small result set
group.bench_function("normal_order_small", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("controller"),
black_box(10),
black_box(4),
black_box(None),
black_box(false),
);
results.total_matched
});
});
group.bench_function("reverse_order_small", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("controller"),
black_box(10),
black_box(4),
black_box(None),
black_box(true),
);
results.total_matched
});
});
group.finish();
}
criterion_group!(
benches,
bench_indexing,
bench_search_queries,
bench_search_thread_scaling,
bench_search_result_limits,
bench_search_scalability,
bench_search_ordering,
);
criterion_main!(benches);
+31 -15
View File
@@ -260,11 +260,11 @@ M.state = {
last_status_info = nil,
search_timer = nil,
search_debounce_ms = 50, -- Debounce delay for search
last_preview_file = nil,
last_preview_location = nil, -- Track last preview location to detect changes
preview_timer = nil, -- Separate timer for preview updates
preview_debounce_ms = 100, -- Preview is more expensive, debounce more
}
function M.create_ui()
@@ -737,12 +737,6 @@ function M.on_input_change()
M.state.query = query
if M.state.search_timer then
M.state.search_timer:stop()
M.state.search_timer:close()
M.state.search_timer = nil
end
M.update_results_sync()
end
@@ -795,6 +789,28 @@ function M.update_results_sync()
M.render_debounced()
end
function M.update_preview_debounced()
-- Cancel previous preview timer
if M.state.preview_timer then
M.state.preview_timer:stop()
M.state.preview_timer:close()
M.state.preview_timer = nil
end
-- Create new timer with longer debounce for expensive preview
M.state.preview_timer = vim.loop.new_timer()
M.state.preview_timer:start(
M.state.preview_debounce_ms,
0,
vim.schedule_wrap(function()
if M.state.active then
M.update_preview()
M.state.preview_timer = nil
end
end)
)
end
function M.render_debounced()
vim.schedule(function()
if M.state.active then
@@ -1382,6 +1398,12 @@ function M.close()
end
end
if M.state.preview_timer then
M.state.preview_timer:stop()
M.state.preview_timer:close()
M.state.preview_timer = nil
end
M.state.input_win = nil
M.state.list_win = nil
M.state.file_info_win = nil
@@ -1400,12 +1422,6 @@ function M.close()
M.state.current_file_cache = nil
M.state.location = nil
if M.state.search_timer then
M.state.search_timer:stop()
M.state.search_timer:close()
M.state.search_timer = nil
end
-- Clean up picker focus autocmds
pcall(vim.api.nvim_del_augroup_by_name, 'fff_picker_focus')
end
+84 -50
View File
@@ -3,14 +3,14 @@ use crate::error::Error;
use crate::file_picker::FilePicker;
use crate::git::GitStatusCache;
use git2::Repository;
use notify::RecursiveMode;
use notify_debouncer_mini::{DebounceEventResult, DebouncedEvent, new_debouncer};
use notify::{EventKind, RecursiveMode};
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, RecommendedCache, new_debouncer};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tracing::{debug, error, info, warn};
use tracing::{Level, error, info, warn};
type Debouncer = notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>;
type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, RecommendedCache>;
pub struct BackgroundWatcher {
debouncer: Arc<Mutex<Option<Debouncer>>>,
@@ -38,7 +38,10 @@ impl BackgroundWatcher {
base_path: PathBuf,
git_workdir: Option<PathBuf>,
) -> Result<Debouncer, Error> {
let mut debouncer = new_debouncer(DEBOUNCE_TIMEOUT, {
let mut debouncer = new_debouncer(
DEBOUNCE_TIMEOUT,
Some(DEBOUNCE_TIMEOUT / 4), // tick rate for the event span
{
move |result: DebounceEventResult| match result {
Ok(events) => {
if !events.is_empty() {
@@ -49,11 +52,10 @@ impl BackgroundWatcher {
error!("File watcher errors: {:?}", errors);
}
}
})?;
},
)?;
debouncer
.watcher()
.watch(base_path.as_path(), RecursiveMode::Recursive)?;
debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?;
info!("File watcher initizlieed for path: {}", base_path.display());
Ok(debouncer)
@@ -81,34 +83,31 @@ impl Drop for BackgroundWatcher {
}
}
#[tracing::instrument(skip(events), level = Level::DEBUG)]
fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<PathBuf>) {
debug!("Processing {} debounced events", events.len());
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
error!("Failed to acquire file picker write lock");
return;
};
let Some(ref mut picker) = *file_picker_guard else {
error!("File picker not initialized");
return;
};
let mut need_full_git_rescan = false;
// this will be called very often, we have to minimiy the lock time for file picker
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
let mut files_to_update_git_status = Vec::with_capacity(events.len() * 2);
let mut need_full_rescan = false;
let mut need_full_git_rescan = false;
let mut paths_to_remove = Vec::new();
let mut paths_to_add_or_modify = Vec::new();
let mut affected_paths_count = 0usize;
for event in &events {
let path = &event.path;
for debounced_event in &events {
// It is very important to not react to the access errors because we inevitably
// gonna trigger the sync by our own preview
if matches!(debounced_event.event.kind, EventKind::Access(_)) {
continue;
}
for path in &debounced_event.event.paths {
if is_ignore_definition_path(path) {
info!(
"Detected change in the ignore definition file: {}",
"Detected change in ignore definition file: {}",
path.display()
);
return trigger_full_rescan(picker);
need_full_rescan = true;
break;
}
if is_dotgit_change_affecting_status(path, &repo) {
@@ -119,41 +118,82 @@ fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<Pat
continue;
}
debug!("Handling fs event: {:?}", event);
if !path.exists() {
paths_to_remove.push(path.as_path());
} else {
paths_to_add_or_modify.push(path.as_path());
}
}
affected_paths_count += 1;
affected_paths_count += debounced_event.event.paths.len();
if affected_paths_count > MAX_PATHS_THRESHOLD {
warn!(
"Too many affected paths ({}) in a single batch, triggering full rescan",
affected_paths_count
);
return trigger_full_rescan(picker);
need_full_rescan = true;
break;
}
if !path.exists() {
picker.remove_file_by_path(path);
continue;
if need_full_rescan {
break;
}
}
let file = picker.on_create_or_modify(path);
if let Some(file) = file {
files_to_update_git_status.push(file.relative_path.clone());
}
if need_full_rescan {
error!("NEED A FULL RESCAN");
return;
}
let Some(repo) = repo.as_ref() else {
return;
};
if need_full_git_rescan {
drop(file_picker_guard); // it's going to be relocked after rescan
info!("Triggering full git rescan by the notification results");
if let Err(e) = FilePicker::refresh_git_status_global() {
error!("Failed to refresh git status: {:?}", e);
}
} else if let Some(repo) = repo.as_ref() {
let status = GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status);
if let Err(e) = picker.update_git_statuses(status) {
error!("Failed to update git statuses: {:?}", e);
return;
}
let files_to_update_git_status = {
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
error!("Failed to acquire file picker write lock");
return;
};
let Some(ref mut picker) = *file_picker_guard else {
error!("File picker not initialized");
return;
};
// Apply file removals
for path in paths_to_remove {
picker.remove_file_by_path(path);
}
// Apply file additions/modifications and collect paths for git status update
let mut files_to_update_git_status = Vec::with_capacity(paths_to_add_or_modify.len());
for path in paths_to_add_or_modify {
if let Some(file) = picker.on_create_or_modify(path) {
files_to_update_git_status.push(file.relative_path.clone());
}
}
files_to_update_git_status
};
let status = GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status);
// only lock the picker for theshortest possitble time
if let Ok(mut file_picker_guard) = FILE_PICKER.write()
&& let Some(ref mut picker) = *file_picker_guard
&& let Err(e) = picker.update_git_statuses(status)
{
error!("Failed to update git statuses: {:?}", e);
}
}
@@ -166,12 +206,6 @@ fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
.is_some_and(|repo| repo.is_path_ignored(path) == Ok(false))
}
fn trigger_full_rescan(picker: &mut FilePicker) {
if let Err(e) = picker.trigger_rescan() {
error!("Failed to trigger full rescan: {:?}", e);
}
}
#[inline]
fn is_git_file(path: &Path) -> bool {
path.components()
+4 -1
View File
@@ -66,7 +66,9 @@ impl FileItem {
Self {
path,
relative_path_lower: relative_path.to_lowercase(),
relative_path,
file_name_lower: name.to_lowercase(),
file_name: name,
size,
modified,
@@ -592,7 +594,8 @@ fn scan_filesystem(
files.len()
);
files.par_sort_unstable_by(|a, b| a.path.cmp(&b.path));
// Sort by OsStr instead of Path to avoid expensive component-by-component comparison
files.par_sort_unstable_by(|a, b| a.path.as_os_str().cmp(b.path.as_os_str()));
Ok(FileSync { files, git_workdir })
})
}
+8 -4
View File
@@ -15,6 +15,7 @@ pub mod git;
mod location;
mod path_utils;
pub mod score;
mod sort_buffer;
mod tracing;
pub mod types;
use mimalloc::MiMalloc;
@@ -120,16 +121,19 @@ pub fn fuzzy_search_files(
}
pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
let file_path = PathBuf::from(&file_path);
// Track access in frecency DB (expensive LMDB write, ~100-200ms)
// Do this WITHOUT holding FILE_PICKER lock to avoid blocking searches
let Some(ref frecency) = *FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)? else {
return Ok(false);
};
frecency.track_access(file_path.as_path())?;
// Quick lock to update single file's frecency score in picker
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
return Err(Error::FilePickerMissing)?;
};
let file_path = PathBuf::from(&file_path).canonicalize()?;
frecency.track_access(file_path.as_path())?;
picker.update_single_file_frecency(&file_path, frecency)?;
Ok(true)
+249 -11
View File
@@ -3,6 +3,7 @@ use std::path::MAIN_SEPARATOR;
use crate::{
git::is_modified_status,
path_utils::calculate_distance_penalty,
sort_buffer::{sort_by_key_with_buffer, sort_with_buffer},
types::{FileItem, Score, ScoringContext},
};
use neo_frizbee::Scoring;
@@ -33,9 +34,9 @@ pub fn match_and_score_files<'a>(
};
let query_contains_path_separator = context.query.contains(MAIN_SEPARATOR);
let haystack: Vec<_> = files
let haystack: Vec<&str> = files
.iter()
.map(|f| f.relative_path.to_lowercase())
.map(|f| f.relative_path_lower.as_str())
.collect();
tracing::debug!(
"Starting fuzzy search for query '{}' in {} files",
@@ -52,27 +53,39 @@ pub fn match_and_score_files<'a>(
// assume that filename should only match if the path matches
// we should actually incorporate this bonus by getting this information from neo_frizbee directly
// instead of spawning a separate matching process, but it's okay for the beta
let haystack_of_filenames = path_matches
.par_iter()
// Use sequential iteration - this is a simple filtering operation that's faster without Rayon overhead
let haystack_of_filenames: Vec<&str> = path_matches
.iter()
.filter_map(|m| {
files
.get(m.index as usize)
.map(|f| f.file_name.to_lowercase())
.map(|f| f.file_name_lower.as_str())
})
.collect::<Vec<_>>();
.collect();
// if there is a / in the query we don't even match filenames
let filename_matches = if query_contains_path_separator {
vec![]
} else {
let mut list = neo_frizbee::match_list_parallel(
// Use parallel matching only if we have enough filenames to justify overhead
// Sequential matching is faster for small result sets (< 1000 matches)
let mut list = if haystack_of_filenames.len() > 1000 {
neo_frizbee::match_list_parallel(
context.query,
&haystack_of_filenames,
&options,
context.max_threads,
);
)
} else {
neo_frizbee::match_list(context.query, &haystack_of_filenames, &options)
};
// Sequential sort is faster for small lists
if list.len() > 1000 {
list.par_sort_unstable_by_key(|m| m.index);
} else {
sort_by_key_with_buffer(&mut list, |m| m.index);
}
list
};
@@ -250,30 +263,255 @@ fn calculate_current_file_penalty(
}
/// Dynamically sorts and returns the top results either in ascending or descending order
/// Uses partial sorting for large result sets to improve performance
fn sort_and_truncate<'a>(
mut results: Vec<(&'a FileItem, Score)>,
context: &ScoringContext,
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
let total_matched = results.len();
// For large result sets, use partial sort to avoid sorting everything
let threshold = context.max_results * 2;
if context.reverse_order {
results.sort_by(|a, b| {
// Ascending order: want highest N items displayed as [low -> high]
if results.len() > threshold {
// Partition at position (len - max_results) with ascending comparator
// This puts the highest max_results items after this position
let partition_index = results.len() - context.max_results;
results.select_nth_unstable_by(partition_index, |a, b| {
a.1.total
.cmp(&b.1.total)
.then_with(|| a.0.modified.cmp(&b.0.modified))
});
// Remove everything before partition_index, keeping highest max_results items
results.drain(0..partition_index);
}
// Sort remaining results in ascending order using glidesort
sort_with_buffer(&mut results, |a, b| {
a.1.total
.cmp(&b.1.total)
.then_with(|| a.0.modified.cmp(&b.0.modified))
});
// If still more than max_results (for small datasets), drain the front
if results.len() > context.max_results {
results.drain(0..(total_matched - context.max_results));
results.drain(0..(results.len() - context.max_results));
}
} else {
results.sort_by(|a, b| {
// Descending order: want highest N items displayed as [high -> low]
if results.len() > threshold {
// Partition at position (max_results - 1) with descending comparator
// This puts the highest max_results items at the front
results.select_nth_unstable_by(context.max_results - 1, |a, b| {
b.1.total
.cmp(&a.1.total)
.then_with(|| b.0.modified.cmp(&a.0.modified))
});
// Keep only the first max_results items
results.truncate(context.max_results);
}
// Sort remaining results in descending order using glidesort
sort_with_buffer(&mut results, |a, b| {
b.1.total
.cmp(&a.1.total)
.then_with(|| b.0.modified.cmp(&a.0.modified))
});
// Ensure we only return max_results items (for small datasets)
results.truncate(context.max_results);
}
let (items, scores) = results.into_iter().unzip();
(items, scores, total_matched)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn create_test_file(path: &str, score: i32, modified: u64) -> (FileItem, Score) {
let file = FileItem {
path: PathBuf::from(path),
relative_path: path.to_string(),
relative_path_lower: path.to_lowercase(),
file_name: path.split('/').last().unwrap_or(path).to_string(),
file_name_lower: path.split('/').last().unwrap_or(path).to_lowercase(),
size: 0,
modified,
access_frecency_score: 0,
modification_frecency_score: 0,
total_frecency_score: 0,
git_status: None,
};
let score_obj = Score {
total: score,
base_score: score,
filename_bonus: 0,
distance_penalty: 0,
special_filename_bonus: 0,
current_file_penalty: 0,
frecency_boost: 0,
exact_match: false,
match_type: "test",
};
(file, score_obj)
}
#[test]
fn test_partial_sort_descending() {
// Create test data with known scores
let test_data = vec![
create_test_file("file1.rs", 100, 1000),
create_test_file("file2.rs", 200, 2000),
create_test_file("file3.rs", 50, 3000),
create_test_file("file4.rs", 300, 4000),
create_test_file("file5.rs", 150, 5000),
create_test_file("file6.rs", 250, 6000),
create_test_file("file7.rs", 80, 7000),
create_test_file("file8.rs", 180, 8000),
create_test_file("file9.rs", 120, 9000),
create_test_file("file10.rs", 90, 10000),
];
// Convert to references like the actual function uses
let results: Vec<(&FileItem, Score)> = test_data
.iter()
.map(|(file, score)| (file, score.clone()))
.collect();
let context = ScoringContext {
query: "test",
max_results: 3,
max_threads: 1,
max_typos: 2,
current_file: None,
reverse_order: false,
};
// Test with partial sort (threshold = 3 * 2 = 6, our len is 10 > 6)
let (items, scores, total) = sort_and_truncate(results.clone(), &context);
// Should return top 3: 300, 250, 200
assert_eq!(total, 10);
assert_eq!(scores.len(), 3);
assert_eq!(scores[0].total, 300, "First should be highest score");
assert_eq!(scores[1].total, 250, "Second should be second highest");
assert_eq!(scores[2].total, 200, "Third should be third highest");
// Verify the files match
assert_eq!(items[0].relative_path, "file4.rs");
assert_eq!(items[1].relative_path, "file6.rs");
assert_eq!(items[2].relative_path, "file2.rs");
}
#[test]
fn test_partial_sort_with_same_scores() {
// Test tiebreaker with modified time
let test_data = vec![
create_test_file("file1.rs", 100, 5000), // Same score, older
create_test_file("file2.rs", 100, 8000), // Same score, newer
create_test_file("file3.rs", 100, 3000), // Same score, oldest
create_test_file("file4.rs", 200, 1000),
create_test_file("file5.rs", 200, 9000), // Higher score, newest
];
let results: Vec<(&FileItem, Score)> = test_data
.iter()
.map(|(file, score)| (file, score.clone()))
.collect();
let context = ScoringContext {
query: "test",
max_results: 3,
max_threads: 1,
max_typos: 2,
current_file: None,
reverse_order: false,
};
let (items, scores, _) = sort_and_truncate(results, &context);
// Should return: 200(9000), 200(1000), 100(8000)
assert_eq!(scores[0].total, 200);
assert_eq!(items[0].modified, 9000, "First 200 should be newest");
assert_eq!(scores[1].total, 200);
assert_eq!(items[1].modified, 1000, "Second 200 should be older");
assert_eq!(scores[2].total, 100);
assert_eq!(items[2].modified, 8000, "Third should be newest of 100s");
}
#[test]
fn test_no_partial_sort_for_small_results() {
// When results.len() <= threshold, should use regular sort
let test_data = vec![
create_test_file("file1.rs", 100, 1000),
create_test_file("file2.rs", 200, 2000),
create_test_file("file3.rs", 50, 3000),
];
let results: Vec<(&FileItem, Score)> = test_data
.iter()
.map(|(file, score)| (file, score.clone()))
.collect();
let context = ScoringContext {
query: "test",
max_results: 2,
max_threads: 1,
max_typos: 2,
current_file: None,
reverse_order: false,
};
// threshold = 2 * 2 = 4, len = 3 < 4, so regular sort
let (items, scores, _) = sort_and_truncate(results, &context);
assert_eq!(scores.len(), 2);
assert_eq!(scores[0].total, 200);
assert_eq!(scores[1].total, 100);
assert_eq!(items[0].relative_path, "file2.rs");
assert_eq!(items[1].relative_path, "file1.rs");
}
#[test]
fn test_reverse_order_partial_sort() {
let test_data = vec![
create_test_file("file1.rs", 100, 1000),
create_test_file("file2.rs", 200, 2000),
create_test_file("file3.rs", 50, 3000),
create_test_file("file4.rs", 300, 4000),
create_test_file("file5.rs", 150, 5000),
create_test_file("file6.rs", 250, 6000),
];
let results: Vec<(&FileItem, Score)> = test_data
.iter()
.map(|(file, score)| (file, score.clone()))
.collect();
let context = ScoringContext {
query: "test",
max_results: 3,
max_threads: 1,
max_typos: 2,
current_file: None,
reverse_order: true,
};
let (items, scores, _) = sort_and_truncate(results, &context);
// Reverse order should return highest 3 in ascending order: 200, 250, 300
// This is for bottom-prompt UI where best results appear at bottom near the prompt
assert_eq!(scores.len(), 3);
assert_eq!(scores[0].total, 200, "First should be third highest");
assert_eq!(scores[1].total, 250, "Second should be second highest");
assert_eq!(scores[2].total, 300, "Third should be highest");
assert_eq!(items[0].relative_path, "file2.rs");
assert_eq!(items[1].relative_path, "file6.rs");
assert_eq!(items[2].relative_path, "file4.rs");
}
}
+155
View File
@@ -0,0 +1,155 @@
//! Thread-local sort buffer management for glidesort optimization
//!
//! This module provides thread-local buffers for glidesort's with_buffer API,
//! eliminating allocations in the hot path of fuzzy search operations.
use std::cell::RefCell;
use std::mem::MaybeUninit;
// glidesort requires a buffer to allocate, we use one reused buffer as it can grow pretty big
// for a large projects, this effectively saves 12kb of allocation on every search in linux repo
thread_local! {
static SORT_BUFFER: RefCell<Vec<u8>> = RefCell::new(Vec::with_capacity(1024));
}
pub fn sort_with_buffer<T, F>(slice: &mut [T], compare: F)
where
F: FnMut(&T, &T) -> std::cmp::Ordering,
{
SORT_BUFFER.with(|buffer| {
let mut buffer = buffer.borrow_mut();
// Calculate required buffer size in u8 units
let size_of_t = std::mem::size_of::<MaybeUninit<T>>();
let size_of_usize = std::mem::size_of::<u8>();
let required_usizes = (slice.len() * size_of_t).div_ceil(size_of_usize);
// Ensure buffer has enough capacity
if buffer.len() < required_usizes {
buffer.resize(required_usizes, 0);
}
// Cast u8 buffer to MaybeUninit<T> slice
// SAFETY: u8 provides sufficient alignment for most types, and we've ensured
// the buffer is large enough
let typed_buffer = unsafe {
std::slice::from_raw_parts_mut(buffer.as_mut_ptr() as *mut MaybeUninit<T>, slice.len())
};
glidesort::sort_with_buffer_by(slice, typed_buffer, compare);
});
}
pub fn sort_by_key_with_buffer<T, K, F>(slice: &mut [T], key_fn: F)
where
K: Ord,
F: FnMut(&T) -> K,
{
SORT_BUFFER.with(|buffer| {
let mut buffer = buffer.borrow_mut();
// Calculate required buffer size in u8 units
let size_of_t = std::mem::size_of::<MaybeUninit<T>>();
let size_of_usize = std::mem::size_of::<u8>();
let required_usizes = (slice.len() * size_of_t).div_ceil(size_of_usize);
// Ensure buffer has enough capacity
if buffer.len() < required_usizes {
buffer.resize(required_usizes, 0);
}
// Cast u8 buffer to MaybeUninit<T> slice
// SAFETY: u8 provides sufficient alignment for most types, and we've ensured
// the buffer is large enough
let typed_buffer = unsafe {
std::slice::from_raw_parts_mut(buffer.as_mut_ptr() as *mut MaybeUninit<T>, slice.len())
};
glidesort::sort_with_buffer_by_key(slice, typed_buffer, key_fn);
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sort_with_buffer() {
let mut data = vec![5, 2, 8, 1, 9];
sort_with_buffer(&mut data, |a, b| a.cmp(b));
assert_eq!(data, vec![1, 2, 5, 8, 9]);
}
#[test]
fn test_sort_by_key_with_buffer() {
let mut data = vec![(2, "b"), (1, "a"), (3, "c")];
sort_by_key_with_buffer(&mut data, |item| item.0);
assert_eq!(data, vec![(1, "a"), (2, "b"), (3, "c")]);
}
#[test]
fn test_reverse_sort() {
let mut data = vec![1, 2, 3, 4, 5];
sort_with_buffer(&mut data, |a, b| b.cmp(a));
assert_eq!(data, vec![5, 4, 3, 2, 1]);
}
#[test]
fn test_multiple_sorts_reuse_buffer() {
// This test verifies that multiple sorts on the same thread reuse the buffer
let mut data1 = vec![5, 2, 8, 1, 9];
sort_with_buffer(&mut data1, |a, b| a.cmp(b));
let mut data2 = vec![15, 12, 18, 11, 19];
sort_with_buffer(&mut data2, |a, b| a.cmp(b));
assert_eq!(data1, vec![1, 2, 5, 8, 9]);
assert_eq!(data2, vec![11, 12, 15, 18, 19]);
}
#[test]
fn test_empty_slice() {
let mut data: Vec<i32> = vec![];
sort_with_buffer(&mut data, |a, b| a.cmp(b));
assert_eq!(data, Vec::<i32>::new());
}
#[test]
fn test_single_element() {
let mut data = vec![42];
sort_with_buffer(&mut data, |a, b| a.cmp(b));
assert_eq!(data, vec![42]);
}
#[test]
fn test_already_sorted() {
let mut data = vec![1, 2, 3, 4, 5];
sort_with_buffer(&mut data, |a, b| a.cmp(b));
assert_eq!(data, vec![1, 2, 3, 4, 5]);
}
#[test]
fn test_with_duplicates() {
let mut data = vec![3, 1, 4, 1, 5, 9, 2, 6, 5];
sort_with_buffer(&mut data, |a, b| a.cmp(b));
assert_eq!(data, vec![1, 1, 2, 3, 4, 5, 5, 6, 9]);
}
#[test]
fn test_descending_order() {
let mut data = vec![3, 1, 4, 1, 5, 9, 2, 6, 5];
sort_with_buffer(&mut data, |a, b| b.cmp(a));
assert_eq!(data, vec![9, 6, 5, 5, 4, 3, 2, 1, 1]);
}
#[test]
fn test_simple_descending() {
// Simple test to verify highest scores come first
let mut data = vec![100, 300, 200];
sort_with_buffer(&mut data, |a, b| b.cmp(a));
println!("After sort: {:?}", data);
assert_eq!(data[0], 300, "Highest should be first");
assert_eq!(data[1], 200, "Middle should be second");
assert_eq!(data[2], 100, "Lowest should be last");
}
}
+2
View File
@@ -7,7 +7,9 @@ use crate::{git::format_git_status, location::Location};
pub struct FileItem {
pub path: PathBuf,
pub relative_path: String,
pub relative_path_lower: String,
pub file_name: String,
pub file_name_lower: String,
pub size: u64,
pub modified: u64,
pub access_frecency_score: i64,
+130
View File
@@ -0,0 +1,130 @@
/// Simple search profiler that directly uses scan_filesystem without background thread overhead
use fff_nvim::file_picker::FilePicker;
use std::time::Instant;
fn main() {
let big_repo_path = std::path::PathBuf::from("./big-repo");
if !big_repo_path.exists() {
eprintln!(
"./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo"
);
return;
}
let canonical_path = big_repo_path
.canonicalize()
.expect("Failed to canonicalize path");
eprintln!("Loading files from: {:?}", canonical_path);
// Directly scan without background thread
let start = Instant::now();
let files = {
use ignore::WalkBuilder;
let mut files = Vec::new();
WalkBuilder::new(&canonical_path)
.hidden(false)
.build()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
.for_each(|entry| {
let path = entry.path().to_path_buf();
let relative =
pathdiff::diff_paths(&path, &canonical_path).unwrap_or_else(|| path.clone());
let relative_path = relative.to_string_lossy().into_owned();
let file_name = entry.file_name().to_string_lossy().into_owned();
files.push(fff_nvim::types::FileItem {
path,
relative_path_lower: relative_path.to_lowercase(),
relative_path,
file_name_lower: file_name.to_lowercase(),
file_name,
size: entry.metadata().ok().map_or(0, |m| m.len()),
modified: 0,
access_frecency_score: 0,
modification_frecency_score: 0,
total_frecency_score: 0,
git_status: None,
});
});
files
};
eprintln!(
"✓ Loaded {} files in {:.2}s\n",
files.len(),
start.elapsed().as_secs_f64()
);
// Test queries
let test_queries = vec![
("short_common", "mod", 500),
("medium_specific", "controller", 200),
("long_rare", "user_authentication", 100),
("typo_resistant", "contrlr", 200),
("path_like", "src/lib", 150),
("single_char", "a", 300),
("two_char", "st", 300),
("partial_word", "test", 200),
("deep_path", "drivers/net", 100),
("extension", ".rs", 200),
];
eprintln!("Running search profiler...");
eprintln!("Query | Iterations | Total Time | Avg Time | Matches");
eprintln!("----------------------|------------|------------|-----------|--------");
let global_start = Instant::now();
let mut total_iterations = 0;
for (name, query, iterations) in test_queries {
let start = Instant::now();
let mut match_count = 0;
for _ in 0..iterations {
let results = FilePicker::fuzzy_search(
&files, query, 100, // max_results
4, // max_threads
None, // current_file
false, // reverse_order
);
match_count += results.total_matched;
}
let elapsed = start.elapsed();
let avg_time = elapsed / iterations as u32;
eprintln!(
"{:<21} | {:>10} | {:>9.2}s | {:>7}µs | {}",
name,
iterations,
elapsed.as_secs_f64(),
avg_time.as_micros(),
match_count / iterations
);
total_iterations += iterations;
}
let total_time = global_start.elapsed();
eprintln!("\n=== Summary ===");
eprintln!("Total searches: {}", total_iterations);
eprintln!("Total time: {:.2}s", total_time.as_secs_f64());
eprintln!(
"Average per search: {}µs",
(total_time.as_micros() as usize) / total_iterations
);
eprintln!(
"Searches per sec: {:.0}",
total_iterations as f64 / total_time.as_secs_f64()
);
eprintln!(
"\nYou can now run: perf record -g --call-graph dwarf -F 999 ./target/release/search_only"
);
}
+164
View File
@@ -0,0 +1,164 @@
use fff_nvim::FILE_PICKER;
use fff_nvim::file_picker::FilePicker;
use std::time::{Duration, Instant};
/// Wait for background scan to complete
fn wait_for_scan(timeout_secs: u64) -> Result<usize, String> {
let start = Instant::now();
let timeout = Duration::from_secs(timeout_secs);
let mut iteration = 0;
loop {
iteration += 1;
let picker_guard = FILE_PICKER
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
let is_scanning = picker.is_scan_active();
let file_count = picker.get_files().len();
if iteration % 20 == 0 {
eprintln!(
" [{:.1}s] Scanning: {}, Files: {}",
start.elapsed().as_secs_f64(),
is_scanning,
file_count
);
}
if !is_scanning && file_count > 0 {
return Ok(file_count);
}
} else if iteration % 20 == 0 {
eprintln!(
" [{:.1}s] FilePicker is None",
start.elapsed().as_secs_f64()
);
}
if start.elapsed() > timeout {
return Err(format!("Scan timed out after {} seconds", timeout_secs));
}
std::thread::sleep(Duration::from_millis(100));
}
}
/// Initialize FilePicker and insert into global state
fn init_file_picker(path: &str) -> Result<(), String> {
let picker = FilePicker::new(path.to_string())
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))?;
let mut picker_guard = FILE_PICKER
.write()
.map_err(|_| "Failed to acquire write lock")?;
*picker_guard = Some(picker);
Ok(())
}
/// Get files snapshot from global state
fn get_files() -> Result<Vec<fff_nvim::types::FileItem>, String> {
let picker_guard = FILE_PICKER
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
Ok(picker.get_files().to_vec())
} else {
Err("FilePicker not initialized".to_string())
}
}
fn main() {
let big_repo_path = std::path::PathBuf::from("./big-repo");
if !big_repo_path.exists() {
eprintln!(
"./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo"
);
return;
}
let canonical_path = big_repo_path
.canonicalize()
.expect("Failed to canonicalize path");
eprintln!("Initializing FilePicker for: {:?}", canonical_path);
init_file_picker(&canonical_path.to_string_lossy()).expect("Failed to init FilePicker");
// Give background thread time to start
std::thread::sleep(Duration::from_millis(200));
eprintln!("Waiting for scan to complete...");
let file_count = wait_for_scan(120).expect("Failed to wait for scan");
eprintln!("✓ Indexed {} files\n", file_count);
let files = get_files().expect("Failed to get files");
// Test queries representing different search patterns
let test_queries = vec![
("short_common", "mod", 5000),
("medium_specific", "controller", 2000),
("long_rare", "user_authentication", 1000),
("typo_resistant", "contrlr", 2000),
("path_like", "src/lib", 1500),
("single_char", "a", 3000),
("two_char", "st", 3000),
("partial_word", "test", 2000),
("deep_path", "drivers/net", 1000),
("extension", ".rs", 2000),
];
eprintln!("Running search profiler...");
eprintln!("Query | Iterations | Total Time | Avg Time | Matches");
eprintln!("----------------------|------------|------------|-----------|--------");
let global_start = Instant::now();
let mut total_iterations = 0;
for (name, query, iterations) in test_queries {
let start = Instant::now();
let mut match_count = 0;
for _ in 0..iterations {
let results = FilePicker::fuzzy_search(
&files, query, 100, // max_results
4, // max_threads
None, // current_file
false, // reverse_order
);
match_count += results.total_matched;
}
let elapsed = start.elapsed();
let avg_time = elapsed / iterations as u32;
eprintln!(
"{:<21} | {:>10} | {:>9.2}s | {:>7}µs | {}",
name,
iterations,
elapsed.as_secs_f64(),
avg_time.as_micros(),
match_count / iterations
);
total_iterations += iterations;
}
let total_time = global_start.elapsed();
eprintln!("\n=== Summary ===");
eprintln!("Total searches: {}", total_iterations);
eprintln!("Total time: {:.2}s", total_time.as_secs_f64());
eprintln!(
"Average per search: {}µs",
(total_time.as_micros() as usize) / total_iterations
);
eprintln!(
"Searches per sec: {:.0}",
total_iterations as f64 / total_time.as_secs_f64()
);
// Keep the program alive briefly so perf can capture everything
std::thread::sleep(Duration::from_millis(100));
}