Skip to content

Commit 280333a

Browse files
committed
fix(diffctx): reduce peak RSS via chunked fragmentation, streaming cache, mimalloc
1 parent 3759e05 commit 280333a

5 files changed

Lines changed: 78 additions & 47 deletions

File tree

diffctx/Cargo.lock

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

diffctx/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ lang-extra = [
6363
]
6464

6565
[dependencies]
66+
mimalloc = { version = "0.1", default-features = false }
6667
clap = { version = "4", features = ["derive"] }
6768
tree-sitter = "0.25"
6869
tree-sitter-python = { version = "0.23", optional = true }

diffctx/src/fragmentation.rs

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -253,37 +253,45 @@ pub fn process_files_for_fragments(
253253
let max_frags = LIMITS.max_fragments;
254254
let max_generated = LIMITS.max_generated_fragments;
255255

256-
let file_contents: Vec<(PathBuf, String)> = files
257-
.iter()
258-
.filter_map(|file_path| {
259-
let content = read_file_content(
260-
file_path,
261-
root_dir,
262-
preferred_revs,
263-
batch_reader.as_deref_mut(),
264-
)?;
265-
Some((file_path.clone(), content))
266-
})
267-
.collect();
268-
269-
let parsed: Vec<Vec<Fragment>> = file_contents
270-
.par_iter()
271-
.map(|(file_path, content)| {
272-
let path_arc: Arc<str> = Arc::from(file_path.to_string_lossy().as_ref());
273-
let mut raw_frags = fragment_file(path_arc, content);
274-
275-
let generated = is_generated_file(file_path, content);
276-
let cap = if generated { max_generated } else { max_frags };
277-
if raw_frags.len() > cap {
278-
raw_frags.sort_by(|a, b| b.line_count().cmp(&a.line_count()));
279-
raw_frags.truncate(cap);
280-
}
281-
if generated {
282-
raw_frags = truncate_generated_fragments(raw_frags);
283-
}
284-
raw_frags
285-
})
286-
.collect();
256+
// Process files in chunks: sequential read (CatFileBatch is &mut, !Send) then
257+
// parallel parse within each chunk. Peak raw-content memory = chunk_size × max_file_size
258+
// instead of N_files × avg_file_size — eliminates OOM on large repos like astropy.
259+
let chunk_size = rayon::current_num_threads().max(1);
260+
let mut parsed: Vec<Vec<Fragment>> = Vec::with_capacity(files.len());
261+
for chunk in files.chunks(chunk_size) {
262+
let chunk_contents: Vec<(PathBuf, String)> = chunk
263+
.iter()
264+
.filter_map(|file_path| {
265+
let content = read_file_content(
266+
file_path,
267+
root_dir,
268+
preferred_revs,
269+
batch_reader.as_deref_mut(),
270+
)?;
271+
Some((file_path.clone(), content))
272+
})
273+
.collect();
274+
parsed.extend(
275+
chunk_contents
276+
.par_iter()
277+
.map(|(file_path, content)| {
278+
let path_arc: Arc<str> = Arc::from(file_path.to_string_lossy().as_ref());
279+
let mut raw_frags = fragment_file(path_arc, content);
280+
let generated = is_generated_file(file_path, content);
281+
let cap = if generated { max_generated } else { max_frags };
282+
if raw_frags.len() > cap {
283+
raw_frags.sort_by(|a, b| b.line_count().cmp(&a.line_count()));
284+
raw_frags.truncate(cap);
285+
}
286+
if generated {
287+
raw_frags = truncate_generated_fragments(raw_frags);
288+
}
289+
raw_frags
290+
})
291+
.collect::<Vec<_>>(),
292+
);
293+
// chunk_contents dropped here — raw file text freed before next chunk
294+
}
287295

288296
let mut fragments: Vec<Fragment> = Vec::new();
289297
for file_frags in parsed {

diffctx/src/main.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
use mimalloc::MiMalloc;
2+
#[global_allocator]
3+
static GLOBAL: MiMalloc = MiMalloc;
4+
15
use std::path::PathBuf;
26

37
use anyhow::Result;

diffctx/src/pipeline.rs

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -552,27 +552,26 @@ fn create_discovery(config: &PipelineConfig) -> Box<dyn DiscoveryStrategy> {
552552
}
553553

554554
fn build_file_cache(candidate_files: &[PathBuf]) -> FxHashMap<PathBuf, String> {
555-
let mut entries: Vec<(PathBuf, String)> = candidate_files
556-
.par_iter()
557-
.filter_map(|f| {
558-
let meta = f.metadata().ok()?;
559-
if meta.len() as usize > LIMITS.max_file_size {
560-
return None;
561-
}
562-
let content = std::fs::read_to_string(f).ok()?;
563-
Some((f.clone(), content))
564-
})
565-
.collect();
566-
entries.sort_by(|a, b| a.0.cmp(&b.0));
567-
555+
// Stream files one at a time to avoid materialising all content before the cap.
556+
// Previous par_iter().collect() allocated the full eligible corpus into an
557+
// intermediate Vec before truncating — on repos with thousands of files this
558+
// caused peak memory far above max_cache_bytes.
559+
let mut sorted = candidate_files.to_vec();
560+
sorted.sort();
568561
let mut cache: FxHashMap<PathBuf, String> = FxHashMap::default();
569562
let mut cache_bytes = 0usize;
570-
for (path, content) in entries {
563+
for path in sorted {
571564
if cache_bytes > GRAPH_FILTERING.max_cache_bytes {
572565
break;
573566
}
574-
cache_bytes += content.len();
575-
cache.insert(path, content);
567+
let Ok(meta) = path.metadata() else { continue };
568+
if meta.len() as usize > LIMITS.max_file_size {
569+
continue;
570+
}
571+
if let Ok(content) = std::fs::read_to_string(&path) {
572+
cache_bytes += content.len();
573+
cache.insert(path, content);
574+
}
576575
}
577576
cache
578577
}

0 commit comments

Comments
 (0)