Skip to main content

scc_cli/
bench.rs

1//! Benchmark harness (docs/TEST_PLAN.md §16, EPIC-240): generate synthetic
2//! repositories and measure cold index / incremental / task-pack latency.
3
4use std::io::Write;
5use std::path::Path;
6use std::time::Instant;
7
8pub struct BenchReport {
9    pub files: usize,
10    pub loc: usize,
11    pub cold_ms: u64,
12    pub incremental_ms: u64,
13    /// P95 of 7 incremental refreshes (sorted[5]); SCC-242.
14    pub incremental_p95_ms: u64,
15    /// Peak resident set size after the cold index, in KiB.
16    pub peak_rss_kib: u64,
17    pub task_pack_ms: u64,
18    pub db_bytes: u64,
19}
20
21/// Peak resident set size in KiB via getrusage. macOS reports ru_maxrss in
22/// bytes, Linux in KiB — normalize to KiB on both.
23fn peak_rss_kib() -> u64 {
24    let mut usage = unsafe { std::mem::zeroed::<libc::rusage>() };
25    if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut usage) } != 0 {
26        return 0;
27    }
28    #[cfg(target_os = "macos")]
29    {
30        usage.ru_maxrss as u64 / 1024
31    }
32    #[cfg(not(target_os = "macos"))]
33    {
34        usage.ru_maxrss as u64
35    }
36}
37
38/// Generate a synthetic repo with `files` source files, each roughly `lines`
39/// LOC, wired with imports and cross-file calls so extraction does real work.
40pub fn generate_repo(dir: &Path, files: usize, lines: usize) -> (usize, usize) {
41    std::fs::create_dir_all(dir).unwrap();
42    let mut loc = 0usize;
43    for i in 0..files {
44        let name = format!("mod_{i:04}");
45        let mut body = String::new();
46        body.push_str(&format!("# module {name}\n"));
47        if i > 0 {
48            body.push_str(&format!(
49                "from mod_{:04} import helper_{:04}\n",
50                i - 1,
51                i - 1
52            ));
53        }
54        let mut line = 3;
55        // one symbol per ~10 lines, calls to earlier modules
56        let symbol_count = (lines / 10).max(2);
57        for s in 0..symbol_count {
58            let sym = format!("func_{s:03}");
59            body.push_str(&format!(
60                "def {sym}(a: int, b: int) -> int:\n    \"\"\"{sym} computes a value.\"\"\"\n"
61            ));
62            line += 2;
63            let mut cur = 0;
64            while cur < 6 {
65                if i > 0 && cur % 3 == 0 {
66                    body.push_str(&format!(
67                        "    r = helper_{:04}(a, {cur})\n",
68                        (i + cur) % files
69                    ));
70                } else {
71                    body.push_str(&format!("    r = a + {cur} * b\n"));
72                }
73                line += 1;
74                cur += 1;
75            }
76            body.push_str("    return r\n");
77            line += 1;
78        }
79        // pad to the requested line count
80        while line < lines {
81            body.push_str("# padding comment for realistic size\n");
82            line += 1;
83        }
84        loc += line;
85        let mut f = std::fs::File::create(dir.join(format!("{name}.py"))).unwrap();
86        f.write_all(body.as_bytes()).unwrap();
87    }
88    (files, loc)
89}
90
91/// Run the benchmark against a generated repo.
92pub fn bench_index(root: &Path, files: usize, lines: usize) -> crate::Result<BenchReport> {
93    let dir = root.join("repo");
94    let (file_count, loc) = generate_repo(&dir, files, lines);
95
96    // cold index
97    let t = Instant::now();
98    crate::commands::cmd_index(&dir, true)?;
99    let cold_ms = t.elapsed().as_millis() as u64;
100    let peak_rss_kib = peak_rss_kib();
101
102    // incremental: refresh 7 times, touching a different file each pass
103    // (SCC-241/242). P95 is sorted[5] of the 7 samples.
104    let mut incremental_durations: Vec<u64> = Vec::with_capacity(7);
105    for i in 0..7usize {
106        let rel = format!("mod_{:04}.py", i % file_count);
107        let extra = format!("\n# incremental edit {i}\n");
108        std::fs::OpenOptions::new()
109            .append(true)
110            .open(dir.join(&rel))?
111            .write_all(extra.as_bytes())?;
112        let t = Instant::now();
113        crate::commands::cmd_index_paths(&dir, &[rel], true)?;
114        incremental_durations.push(t.elapsed().as_millis() as u64);
115    }
116    incremental_durations.sort_unstable();
117    let incremental_ms = incremental_durations[0];
118    let incremental_p95_ms = incremental_durations[5];
119
120    // task pack
121    let t = Instant::now();
122    crate::commands::cmd_context_task(&dir, "find the helper computation", &[], &[], None, true, false)?;
123    let task_pack_ms = t.elapsed().as_millis() as u64;
124
125    let _store = crate::open_store(&dir)?;
126    let db_bytes = std::fs::metadata(crate::db_path(&dir))
127        .map(|m| m.len())
128        .unwrap_or(0);
129
130    Ok(BenchReport {
131        files: file_count,
132        loc,
133        cold_ms,
134        incremental_ms,
135        incremental_p95_ms,
136        peak_rss_kib,
137        task_pack_ms,
138        db_bytes,
139    })
140}
141
142pub fn print_report(r: &BenchReport) {
143    println!("scc bench index");
144    println!("  files:            {}", r.files);
145    println!("  LOC:              {}", r.loc);
146    println!("  cold index:       {} ms", r.cold_ms);
147    println!("  incremental:      {} ms", r.incremental_ms);
148    println!("  incremental p95:  {} ms", r.incremental_p95_ms);
149    println!("  task pack:        {} ms", r.task_pack_ms);
150    println!("  db size:          {} KiB", r.db_bytes / 1024);
151    println!("  peak RSS:         {} MiB", r.peak_rss_kib / 1024);
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn generator_produces_indexable_repo() {
160        let dir = tempfile::TempDir::new().unwrap();
161        let (files, loc) = generate_repo(dir.path(), 5, 40);
162        assert_eq!(files, 5);
163        assert!(loc >= 5 * 40);
164        crate::commands::cmd_index(dir.path(), true).unwrap();
165        let store = crate::open_store(dir.path()).unwrap();
166        assert!(store.stats().unwrap()["files"] >= 5);
167    }
168
169    #[test]
170    fn bench_runs_end_to_end() {
171        let dir = tempfile::TempDir::new().unwrap();
172        let r = bench_index(dir.path(), 8, 50).unwrap();
173        assert_eq!(r.files, 8);
174        assert!(r.cold_ms > 0);
175        assert!(r.db_bytes > 0);
176        // SCC-242: p95 of the 7 incremental refreshes is always measured
177        assert!(r.incremental_p95_ms > 0);
178    }
179}