From 80b3e4c7a27249519fd0e2aa48109ae4c9b2e14e Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Tue, 15 Sep 2026 11:12:52 -0700 Subject: [PATCH 1/4] perf(core): share frizbee hot-loop instantiations to cut binary size ~17% --- crates/fff-core/src/score.rs | 42 +++++++++++++++++------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/crates/fff-core/src/score.rs b/crates/fff-core/src/score.rs index 3dce9dcfa..1e218c43d 100644 --- a/crates/fff-core/src/score.rs +++ b/crates/fff-core/src/score.rs @@ -61,33 +61,29 @@ fn match_fuzzy_parts( return vec![]; } - let resolve = |file: &FileItem, - buf: &mut [*const u8; MAX_PATH_CHUNKS]| - -> Option<(usize, u16)> { resolve_file_chunks(file, arena, buf) }; - - // because we reassemble the vec of reference we have to use a different type - // to narrow down the [&FileItem] which would be resolved by frizbee as && + // A single `&FileItem` resolver keeps frizbee to one monomorphized hot loop + // instead of separate `FileItem` and `&FileItem` copies (~1MB of code each). let resolve_ref = |file: &&FileItem, buf: &mut [*const u8; MAX_PATH_CHUNKS]| -> Option<(usize, u16)> { resolve_file_chunks(file, arena, buf) }; - let first_part_matches = match working_files { - FileItems::All(files) => neo_frizbee::match_list_parallel_resolved( - valid_parts[0], - files, - &resolve, - options, - max_threads, - ), - FileItems::Filtered(files) => neo_frizbee::match_list_parallel_resolved( - valid_parts[0], - files.as_slice(), - &resolve_ref, - options, - max_threads, - ), + let all_refs: Vec<&FileItem>; + let candidates: &[&FileItem] = match working_files { + FileItems::All(files) => { + all_refs = files.iter().collect(); + &all_refs + } + FileItems::Filtered(files) => files, }; + let first_part_matches = neo_frizbee::match_list_parallel_resolved( + valid_parts[0], + candidates, + &resolve_ref, + options, + max_threads, + ); + if valid_parts.len() == 1 { return first_part_matches; } @@ -215,6 +211,8 @@ pub(crate) fn fuzzy_match_byte_offsets_for_page<'q>( ..Default::default() }; + // Match on `&str` so this shares frizbee's instantiation with fuzzy grep. + let path_strs: Vec<&str> = paths.iter().map(String::as_str).collect(); for (idx, part) in parts.iter().copied().enumerate() { let mut part_config = config; if idx > 0 { @@ -222,7 +220,7 @@ pub(crate) fn fuzzy_match_byte_offsets_for_page<'q>( } let mut matcher = neo_frizbee::Matcher::new(part, &part_config); - for mut matched in matcher.match_list_indices(&paths) { + for mut matched in matcher.match_list_indices(&path_strs) { let item_idx = matched.index as usize; let Some(path) = paths.get(item_idx) else { continue; From 5f3e292b0f96152cc2e367ca97f4dafe06fa08e6 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Tue, 15 Sep 2026 11:49:14 -0700 Subject: [PATCH 2/4] chore(core): bump neo_frizbee to 0.13.0 - rebased fork on upstream frizbee 0.13.0; drops the scalar SIMD fallbacks that can never be selected on aarch64 (~3 MB of inlined dead dispatch code) and adds `SortStrategy::Unsorted`, which skips the parallel k-merge fff never needed - API migration: `Config.sort` is now an enum, match indices are `Vec`, and the free `match_list*` functions became `Matcher::new(..).match_list*(..)` | `libfff_nvim.dylib` (release, aarch64) | before | after | |---|---|---| | file size | 8.19 MB | 6.76 MB | | `__text` | 5.85 MB | 4.45 MB | Search latency is unchanged within noise (+1 to +3% on fff's fuzzy_search bench, identical results). --- Cargo.lock | 7 +++-- Cargo.toml | 2 +- crates/fff-core/src/grep/fuzzy_grep.rs | 4 +-- crates/fff-core/src/grep/grep_tests.rs | 6 ++-- crates/fff-core/src/grep/sink.rs | 3 +- crates/fff-core/src/score.rs | 43 +++++++++++++------------- 6 files changed, 34 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 007effa60..a95189d33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1508,9 +1508,12 @@ dependencies = [ [[package]] name = "neo_frizbee" -version = "0.11.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2f6120a8da26bea3587731072111062c5d8c51ca3a3a75a716bd8b735d5882" +checksum = "15569f06e7e88f579edc822d040986c0128f89342b9b98aa3d08929ebd378278" +dependencies = [ + "serde", +] [[package]] name = "nix" diff --git a/Cargo.toml b/Cargo.toml index 0e4873bf3..70e38555d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,7 @@ signal-hook-registry = "1.4" zlob = { version = "=1.6.5" } mlua = { version = "0.11.1", features = ["module", "luajit"] } -neo_frizbee = { version = "0.11.0", features = ["match_end_col"] } +neo_frizbee = { version = "0.13.0", features = ["match_end_col"] } notify = { version = "9.0.0-rc.3" } notify-debouncer-full = { package = "fff-notify-debouncer-full", version = "0.9.4" } once_cell = "1.20.2" diff --git a/crates/fff-core/src/grep/fuzzy_grep.rs b/crates/fff-core/src/grep/fuzzy_grep.rs index fb4143b43..c8f0e2d29 100644 --- a/crates/fff-core/src/grep/fuzzy_grep.rs +++ b/crates/fff-core/src/grep/fuzzy_grep.rs @@ -53,7 +53,7 @@ pub(super) fn fuzzy_grep_search<'a>( &neo_frizbee::Config { // Use the real max_typos so frizbee's SIMD prefilter actually rejects non-matching lines (~2 SIMD instructions per line vs full SW scoring). max_typos: Some(max_typos as u16), - sort: false, + sort: neo_frizbee::SortStrategy::Unsorted, scoring, ..Default::default() }, @@ -271,7 +271,7 @@ pub(super) fn fuzzy_grep_search<'a>( if let (Some(&first), Some(&last)) = (indices.first(), indices.last()) { // reject widely scattered matches - let span = last - first + 1; + let span = (last - first + 1) as usize; if span > max_match_span { continue; } diff --git a/crates/fff-core/src/grep/grep_tests.rs b/crates/fff-core/src/grep/grep_tests.rs index a7c42da34..81ee2d9a5 100644 --- a/crates/fff-core/src/grep/grep_tests.rs +++ b/crates/fff-core/src/grep/grep_tests.rs @@ -46,7 +46,7 @@ fn test_fuzzy_typo_scoring() { let max_typos = (needle.len() / 3).min(2); // 2 let config = neo_frizbee::Config { max_typos: Some(max_typos as u16), - sort: false, + sort: neo_frizbee::SortStrategy::Unsorted, scoring: neo_frizbee::Scoring { exact_match_bonus: 100, ..neo_frizbee::Scoring::default() @@ -58,7 +58,7 @@ fn test_fuzzy_typo_scoring() { // Helper: check if a match would pass our post-filters let passes = |n: &str, h: &str| -> bool { - let Some(mut mi) = neo_frizbee::match_list_indices(n, &[h], &config) + let Some(mut mi) = neo_frizbee::Matcher::new(n, &config).match_list_indices(&[h]) .into_iter() .next() else { @@ -70,7 +70,7 @@ fn test_fuzzy_typo_scoring() { return false; } if let (Some(&first), Some(&last)) = (mi.indices.first(), mi.indices.last()) { - let span = last - first + 1; + let span = (last - first + 1) as usize; if span > max_match_span { return false; } diff --git a/crates/fff-core/src/grep/sink.rs b/crates/fff-core/src/grep/sink.rs index 4111dc986..3fbd28cc0 100644 --- a/crates/fff-core/src/grep/sink.rs +++ b/crates/fff-core/src/grep/sink.rs @@ -201,7 +201,7 @@ pub(super) fn split_multiline_blob(display_bytes: &[u8]) -> (&[u8], Vec) /// Adjacent characters are merged into a single contiguous range. pub(super) fn char_indices_to_byte_offsets( line: &str, - char_indices: &[usize], + char_indices: &[u32], ) -> SmallVec<[(u32, u32); 4]> { if char_indices.is_empty() { return SmallVec::new(); @@ -218,6 +218,7 @@ pub(super) fn char_indices_to_byte_offsets( let mut result: SmallVec<[(u32, u32); 4]> = SmallVec::with_capacity(char_indices.len()); for &ci in char_indices { + let ci = ci as usize; if ci >= char_byte_ranges.len() { continue; // out of bounds (shouldn't happen with valid data) } diff --git a/crates/fff-core/src/score.rs b/crates/fff-core/src/score.rs index 1e218c43d..8796fd8fa 100644 --- a/crates/fff-core/src/score.rs +++ b/crates/fff-core/src/score.rs @@ -202,7 +202,7 @@ pub(crate) fn fuzzy_match_byte_offsets_for_page<'q>( .any(|part| part.chars().any(|ch| ch.is_uppercase())); let config = neo_frizbee::Config { max_typos: Some(max_typos), - sort: false, + sort: neo_frizbee::SortStrategy::Unsorted, scoring: Scoring { capitalization_bonus: if has_uppercase { 8 } else { 0 }, matching_case_bonus: if has_uppercase { 4 } else { 0 }, @@ -238,7 +238,7 @@ pub(crate) fn fuzzy_match_byte_offsets_for_page<'q>( ranges_by_item } -fn char_indices_to_byte_offsets(line: &str, char_indices: &[usize]) -> SmallVec<[(u32, u32); 4]> { +fn char_indices_to_byte_offsets(line: &str, char_indices: &[u32]) -> SmallVec<[(u32, u32); 4]> { let char_byte_ranges: Vec<(usize, usize)> = line .char_indices() .map(|(byte_pos, ch)| (byte_pos, byte_pos + ch.len_utf8())) @@ -246,7 +246,7 @@ fn char_indices_to_byte_offsets(line: &str, char_indices: &[usize]) -> SmallVec< let mut result: SmallVec<[(u32, u32); 4]> = SmallVec::with_capacity(char_indices.len()); for &char_idx in char_indices { - let Some(&(start, end)) = char_byte_ranges.get(char_idx) else { + let Some(&(start, end)) = char_byte_ranges.get(char_idx as usize) else { continue; }; @@ -439,7 +439,7 @@ pub(crate) fn fuzzy_match_and_score_dirs<'a>( let options = neo_frizbee::Config { max_typos: Some(context.max_typos), - sort: false, + sort: neo_frizbee::SortStrategy::Unsorted, scoring: Scoring { capitalization_bonus: if has_uppercase { 8 } else { 0 }, matching_case_bonus: if has_uppercase { 4 } else { 0 }, @@ -643,7 +643,7 @@ fn match_and_score_in_arena<'a>( let options = neo_frizbee::Config { max_typos: Some(context.max_typos), - sort: false, + sort: neo_frizbee::SortStrategy::Unsorted, scoring: Scoring { capitalization_bonus: if has_uppercase { 8 } else { 0 }, matching_case_bonus: if has_uppercase { 4 } else { 0 }, @@ -684,16 +684,15 @@ fn match_and_score_in_arena<'a>( if fallback_filenames.is_empty() { vec![] } else { - let mut matches = neo_frizbee::match_list_parallel( - fuzzy_parts[0], - &fallback_filenames, - &options, - if path_matches.len() > 4096 { - context.max_threads.div_ceil(2048) - } else { - 1 - }, - ); + let mut matches = neo_frizbee::Matcher::new(fuzzy_parts[0], &options) + .match_list_parallel( + &fallback_filenames, + if path_matches.len() > 4096 { + context.max_threads.div_ceil(2048) + } else { + 1 + }, + ); sort_by_key_with_buffer(&mut matches, |m| fallback_indices[m.index as usize]); matches @@ -1494,21 +1493,21 @@ mod filename_bonus_tests { let options = neo_frizbee::Config { max_typos: Some(2), - sort: false, + sort: neo_frizbee::SortStrategy::Unsorted, ..Default::default() }; - let matches = neo_frizbee::match_list("aipart", &[path], &options); + let matches = neo_frizbee::Matcher::new("aipart", &options).match_list(&[path]); assert!(!matches.is_empty(), "'aipart' should match the path"); - let matches = neo_frizbee::match_list("core", &[path], &options); + let matches = neo_frizbee::Matcher::new("core", &options).match_list(&[path]); assert!(!matches.is_empty(), "'core' should match the path"); let co_options = neo_frizbee::Config { max_typos: Some(2), ..options }; - let matches = neo_frizbee::match_list("co", &[path], &co_options); + let matches = neo_frizbee::Matcher::new("co", &co_options).match_list(&[path]); assert!(!matches.is_empty(), "'co' should match the path"); } @@ -1518,14 +1517,14 @@ mod filename_bonus_tests { let options = neo_frizbee::Config { max_typos: Some(2), - sort: false, + sort: neo_frizbee::SortStrategy::Unsorted, ..Default::default() }; - let matches = neo_frizbee::match_list("co", &[path.as_str()], &options); + let matches = neo_frizbee::Matcher::new("co", &options).match_list(&[path.as_str()]); assert!(!matches.is_empty(), "'co' should match the lowercase path"); - let matches = neo_frizbee::match_list("core", &[path.as_str()], &options); + let matches = neo_frizbee::Matcher::new("core", &options).match_list(&[path.as_str()]); assert!( !matches.is_empty(), "'core' should match the lowercase path" From 909d4934e6a4683313dee72d2e3b900c5c2726c8 Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Tue, 15 Sep 2026 14:00:18 -0700 Subject: [PATCH 3/4] perf(core): resolve frizbee candidates by index - `neo_frizbee` 0.13.1 adds `match_range_parallel_resolved(len, resolve, threads)`, resolving items by index instead of through a `&[T]` slice - drops the `Vec<&FileItem>` built for the full list and the per-part `subset` vectors for files and dirs; each pass now addresses survivors through the previous round's matches directly ```rust neo_frizbee::match_range_parallel_resolved( part, survivors.len(), &|index, buf: &mut [*const u8; MAX_PATH_CHUNKS]| { let file = working_files.index(survivors[index as usize].index as usize); resolve_file_chunks(file, arena, buf) }, &part_options, max_threads, ); ``` --- Cargo.lock | 4 +- Cargo.toml | 2 +- crates/fff-core/src/grep/grep_tests.rs | 3 +- crates/fff-core/src/score.rs | 82 ++++++++++++-------------- 4 files changed, 43 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a95189d33..b5d9634bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1508,9 +1508,9 @@ dependencies = [ [[package]] name = "neo_frizbee" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15569f06e7e88f579edc822d040986c0128f89342b9b98aa3d08929ebd378278" +checksum = "31d299e4377053f9a1c6ce6405a5d9f930d4fe6be96d938abeecdd199d2ccca8" dependencies = [ "serde", ] diff --git a/Cargo.toml b/Cargo.toml index 70e38555d..38cbc4830 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,7 @@ signal-hook-registry = "1.4" zlob = { version = "=1.6.5" } mlua = { version = "0.11.1", features = ["module", "luajit"] } -neo_frizbee = { version = "0.13.0", features = ["match_end_col"] } +neo_frizbee = { version = "0.13.1", features = ["match_end_col"] } notify = { version = "9.0.0-rc.3" } notify-debouncer-full = { package = "fff-notify-debouncer-full", version = "0.9.4" } once_cell = "1.20.2" diff --git a/crates/fff-core/src/grep/grep_tests.rs b/crates/fff-core/src/grep/grep_tests.rs index 81ee2d9a5..c7c0adccf 100644 --- a/crates/fff-core/src/grep/grep_tests.rs +++ b/crates/fff-core/src/grep/grep_tests.rs @@ -58,7 +58,8 @@ fn test_fuzzy_typo_scoring() { // Helper: check if a match would pass our post-filters let passes = |n: &str, h: &str| -> bool { - let Some(mut mi) = neo_frizbee::Matcher::new(n, &config).match_list_indices(&[h]) + let Some(mut mi) = neo_frizbee::Matcher::new(n, &config) + .match_list_indices(&[h]) .into_iter() .next() else { diff --git a/crates/fff-core/src/score.rs b/crates/fff-core/src/score.rs index 8796fd8fa..b625c24cd 100644 --- a/crates/fff-core/src/score.rs +++ b/crates/fff-core/src/score.rs @@ -18,6 +18,14 @@ enum FileItems<'a> { } impl<'a> FileItems<'a> { + #[inline] + fn len(&self) -> usize { + match self { + FileItems::All(s) => s.len(), + FileItems::Filtered(v) => v.len(), + } + } + #[inline] fn index(&self, index: usize) -> &'a FileItem { match self { @@ -61,25 +69,14 @@ fn match_fuzzy_parts( return vec![]; } - // A single `&FileItem` resolver keeps frizbee to one monomorphized hot loop - // instead of separate `FileItem` and `&FileItem` copies (~1MB of code each). - let resolve_ref = |file: &&FileItem, - buf: &mut [*const u8; MAX_PATH_CHUNKS]| - -> Option<(usize, u16)> { resolve_file_chunks(file, arena, buf) }; - - let all_refs: Vec<&FileItem>; - let candidates: &[&FileItem] = match working_files { - FileItems::All(files) => { - all_refs = files.iter().collect(); - &all_refs - } - FileItems::Filtered(files) => files, - }; - - let first_part_matches = neo_frizbee::match_list_parallel_resolved( + // Index-based resolver: no `Vec<&FileItem>` materialization for either the + // full list or the per-part subsets, and a single monomorphized hot loop. + let first_part_matches = neo_frizbee::match_range_parallel_resolved( valid_parts[0], - candidates, - &resolve_ref, + working_files.len(), + &|index, buf: &mut [*const u8; MAX_PATH_CHUNKS]| { + resolve_file_chunks(working_files.index(index as usize), arena, buf) + }, options, max_threads, ); @@ -94,16 +91,16 @@ fn match_fuzzy_parts( let mut part_options = *options; part_options.max_typos = options.max_typos.map(|t| t.min(part.len() as u16)); - // Collect the subset of files that survived the previous round. - let subset: Vec<&FileItem> = matches - .iter() - .map(|m| working_files.index(m.index as usize)) - .collect(); - - let sub_matches = neo_frizbee::match_list_parallel_resolved( + // Match only the files that survived the previous round, addressed + // through the previous matches without collecting a subset. + let survivors = &matches; + let sub_matches = neo_frizbee::match_range_parallel_resolved( part, - subset.as_slice(), - &resolve_ref, + survivors.len(), + &|index, buf: &mut [*const u8; MAX_PATH_CHUNKS]| { + let file = working_files.index(survivors[index as usize].index as usize); + resolve_file_chunks(file, arena, buf) + }, &part_options, max_threads, ); @@ -326,15 +323,12 @@ fn match_fuzzy_parts_dirs( return vec![]; } - let resolve_chunks_for_frizbee = - |dir: &&DirItem, buf: &mut [*const u8; MAX_PATH_CHUNKS]| -> Option<(usize, u16)> { - resolve_dir_chunks(dir, arena, overflow_arena, buf) - }; - - let first_part_matches = neo_frizbee::match_list_parallel_resolved( + let first_part_matches = neo_frizbee::match_range_parallel_resolved( valid_parts[0], - working_dirs, - &resolve_chunks_for_frizbee, + working_dirs.len(), + &|index, buf: &mut [*const u8; MAX_PATH_CHUNKS]| { + resolve_dir_chunks(working_dirs[index as usize], arena, overflow_arena, buf) + }, options, max_threads, ); @@ -349,16 +343,16 @@ fn match_fuzzy_parts_dirs( let mut part_options = *options; part_options.max_typos = options.max_typos.map(|t| t.min(part.len() as u16)); - // Collect the subset of dirs that survived the previous round. - let subset: Vec<&DirItem> = matches - .iter() - .map(|m| working_dirs[m.index as usize]) - .collect(); - - let sub_matches = neo_frizbee::match_list_parallel_resolved( + // Match only the dirs that survived the previous round, addressed + // through the previous matches without collecting a subset. + let survivors = &matches; + let sub_matches = neo_frizbee::match_range_parallel_resolved( part, - subset.as_slice(), - &resolve_chunks_for_frizbee, + survivors.len(), + &|index, buf: &mut [*const u8; MAX_PATH_CHUNKS]| { + let dir = working_dirs[survivors[index as usize].index as usize]; + resolve_dir_chunks(dir, arena, overflow_arena, buf) + }, &part_options, max_threads, ); From af2a2c387eedffe0cc807e923af743cbda3cc27f Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Sun, 20 Sep 2026 19:07:53 -0700 Subject: [PATCH 4/4] chore(core): bump neo_frizbee to 0.13.3 --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- crates/fff-core/src/score.rs | 5 ++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b5d9634bd..095747a32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1508,9 +1508,9 @@ dependencies = [ [[package]] name = "neo_frizbee" -version = "0.13.1" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31d299e4377053f9a1c6ce6405a5d9f930d4fe6be96d938abeecdd199d2ccca8" +checksum = "fb0dd0e69b342eaddb657120a21a56f3e887ddc505fac780cf430dbbbb3aed26" dependencies = [ "serde", ] diff --git a/Cargo.toml b/Cargo.toml index 38cbc4830..0fa2e2b74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,7 @@ signal-hook-registry = "1.4" zlob = { version = "=1.6.5" } mlua = { version = "0.11.1", features = ["module", "luajit"] } -neo_frizbee = { version = "0.13.1", features = ["match_end_col"] } +neo_frizbee = { version = "0.13.3", features = ["match_end_col"] } notify = { version = "9.0.0-rc.3" } notify-debouncer-full = { package = "fff-notify-debouncer-full", version = "0.9.4" } once_cell = "1.20.2" @@ -56,7 +56,7 @@ tracing = "0.1" opt-level = 3 lto = "fat" codegen-units = 1 -strip = "debuginfo" +strip = "symbols" [profile.ci] inherits = "release" diff --git a/crates/fff-core/src/score.rs b/crates/fff-core/src/score.rs index b625c24cd..e387a85e0 100644 --- a/crates/fff-core/src/score.rs +++ b/crates/fff-core/src/score.rs @@ -678,9 +678,12 @@ fn match_and_score_in_arena<'a>( if fallback_filenames.is_empty() { vec![] } else { + // Match on `&str` so frizbee reuses the instantiation its index + // resolver already emits instead of a separate `Cow` copy. + let filename_strs: Vec<&str> = fallback_filenames.iter().map(Cow::as_ref).collect(); let mut matches = neo_frizbee::Matcher::new(fuzzy_parts[0], &options) .match_list_parallel( - &fallback_filenames, + &filename_strs, if path_matches.len() > 4096 { context.max_threads.div_ceil(2048) } else {