|
use anyhow::{bail, Result}; |
|
use clap::Parser; |
|
use indicatif::{ProgressBar, ProgressStyle}; |
|
use rusqlite::{params, Connection}; |
|
use serde::Deserialize; |
|
use std::{ |
|
io::Write, |
|
path::{Path, PathBuf}, |
|
sync::Arc, |
|
}; |
|
use sysinfo::{Pid, System}; |
|
use tokio::{ |
|
io::{AsyncBufReadExt, BufReader}, |
|
process::Command, |
|
sync::{Notify, Semaphore}, |
|
time::{timeout, Duration}, |
|
}; |
|
|
|
const WORKER_SRC: &str = include_str!("../worker.cjs"); |
|
|
|
fn default_output_path(root: &Path) -> PathBuf { |
|
let sanitized = root |
|
.to_string_lossy() |
|
.chars() |
|
.map(|c| { |
|
if c.is_alphanumeric() || c == '-' { |
|
c |
|
} else { |
|
'_' |
|
} |
|
}) |
|
.collect::<String>(); |
|
std::env::temp_dir().join(format!("type-profiler-{sanitized}.db")) |
|
} |
|
|
|
// Walk up from a file's directory to find the nearest tsconfig.json, bounded by root. |
|
fn find_nearest_tsconfig(file: &Path, root: &Path, fallback: &Path) -> PathBuf { |
|
let mut dir = file.parent().unwrap_or(file); |
|
loop { |
|
let candidate = dir.join("tsconfig.json"); |
|
if candidate.exists() { |
|
return candidate; |
|
} |
|
if dir == root || dir.parent().is_none_or(|p| p == dir) { |
|
break; |
|
} |
|
dir = dir.parent().unwrap(); |
|
} |
|
fallback.to_path_buf() |
|
} |
|
|
|
#[derive(Parser)] |
|
#[command( |
|
name = "type-profiler", |
|
about = "Profile TypeScript type resolution — per file, per symbol" |
|
)] |
|
struct Cli { |
|
/// Directory containing tsconfig.json |
|
#[arg(default_value = ".")] |
|
root: PathBuf, |
|
|
|
/// SQLite output file [default: /tmp/type-profiler-<sanitized-root>.db] |
|
#[arg(long)] |
|
output: Option<PathBuf>, |
|
|
|
/// Parallel Node.js workers (default: logical CPUs) |
|
#[arg(long)] |
|
workers: Option<usize>, |
|
|
|
/// Kill worker if no new symbol output for this many ms (per-symbol timeout) |
|
#[arg(long, default_value_t = 15_000)] |
|
timeout_ms: u64, |
|
|
|
/// Kill worker if its RSS exceeds this (MB) |
|
#[arg(long, default_value_t = 2048)] |
|
max_rss_mb: u64, |
|
|
|
/// Skip files already recorded in the DB (re-run only changed/new files) |
|
#[arg(long)] |
|
incremental: bool, |
|
|
|
/// Directory names to exclude (can be repeated). node_modules and .claude are always excluded. |
|
#[arg(long, default_values = &["dist", "out", "build", ".git"])] |
|
exclude: Vec<String>, |
|
|
|
/// Output format: sqlite (default), text (stdout), json (NDJSON to stdout) |
|
#[arg(long, default_value = "sqlite")] |
|
format: String, |
|
|
|
/// Re-run only the N files with highest MAX(instantiations) from the existing DB |
|
#[arg(long)] |
|
rerun_worst: Option<u32>, |
|
|
|
/// Skip files where the previous MAX(instantiations) < N (unknown files always included) |
|
#[arg(long)] |
|
threshold: Option<u64>, |
|
|
|
/// Path to a previous results.db to diff against |
|
#[arg(long)] |
|
compare: Option<PathBuf>, |
|
} |
|
|
|
#[derive(Deserialize)] |
|
struct WorkerRecord { |
|
#[serde(default)] |
|
line: u32, |
|
#[serde(default)] |
|
col: u32, |
|
#[serde(default)] |
|
symbol: String, |
|
#[serde(default)] |
|
hover: String, |
|
#[serde(default)] |
|
ms: f64, |
|
#[serde(default, rename = "heapDeltaKb")] |
|
heap_delta_kb: i64, |
|
#[serde(default)] |
|
instantiations: u64, |
|
#[serde(default)] |
|
done: bool, |
|
#[serde(default, rename = "startRssKb")] |
|
start_rss_kb: u64, |
|
#[serde(default, rename = "peakRssKb")] |
|
peak_rss_kb: u64, |
|
} |
|
|
|
struct DbRecord { |
|
file: String, |
|
line: u32, |
|
col: u32, |
|
symbol: String, |
|
hover: String, |
|
ms: f64, |
|
heap_delta_kb: i64, |
|
instantiations: u64, |
|
timed_out: bool, |
|
oom: bool, |
|
} |
|
|
|
struct FileStats { |
|
file: String, |
|
peak_rss_kb: u64, |
|
start_rss_kb: u64, |
|
duration_ms: f64, |
|
symbol_count: u32, |
|
} |
|
|
|
async fn process_file( |
|
file: PathBuf, |
|
worker_js: PathBuf, |
|
tsconfig: PathBuf, |
|
timeout_ms: u64, |
|
max_rss_bytes: u64, |
|
tx: std::sync::mpsc::Sender<DbRecord>, |
|
) -> FileStats { |
|
let t_start = std::time::Instant::now(); |
|
let file_str = file.to_string_lossy().into_owned(); |
|
|
|
let project_root = tsconfig.parent().unwrap_or(&tsconfig); |
|
// --max-old-space-size caps V8 heap so it OOMs fast instead of swapping |
|
let heap_mb = (max_rss_bytes / 1_000_000).to_string(); |
|
let mut child = match Command::new("node") |
|
.arg(format!("--max-old-space-size={heap_mb}")) |
|
.arg(&worker_js) |
|
.arg(&file) |
|
.arg(&tsconfig) |
|
.current_dir(project_root) |
|
.stdout(std::process::Stdio::piped()) |
|
.stderr(std::process::Stdio::null()) |
|
.spawn() |
|
{ |
|
Ok(c) => c, |
|
Err(e) => { |
|
eprintln!("spawn failed for {file_str}: {e}"); |
|
return FileStats { |
|
file: file_str, |
|
peak_rss_kb: 0, |
|
start_rss_kb: 0, |
|
duration_ms: 0.0, |
|
symbol_count: 0, |
|
}; |
|
} |
|
}; |
|
|
|
let pid = child.id(); |
|
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines(); |
|
|
|
// RSS monitor — separate task polls every 500ms |
|
let oom_notify = Arc::new(Notify::new()); |
|
if let Some(pid_u32) = pid { |
|
let notify = Arc::clone(&oom_notify); |
|
tokio::spawn(async move { |
|
let mut sys = System::new(); |
|
let pid = Pid::from_u32(pid_u32); |
|
loop { |
|
tokio::time::sleep(Duration::from_millis(500)).await; |
|
sys.refresh_process(pid); |
|
match sys.process(pid) { |
|
Some(p) if p.memory() > max_rss_bytes => { |
|
notify.notify_one(); |
|
return; |
|
} |
|
None => return, |
|
_ => {} |
|
} |
|
} |
|
}); |
|
} |
|
|
|
// Pin the OOM future so it survives across select! iterations |
|
let oom_fut = oom_notify.notified(); |
|
tokio::pin!(oom_fut); |
|
|
|
let mut timed_out = false; |
|
let mut rss_oom = false; |
|
let mut done_received = false; |
|
let mut start_rss_kb = 0u64; |
|
let mut peak_rss_kb = 0u64; |
|
let mut symbol_count = 0u32; |
|
|
|
loop { |
|
tokio::select! { |
|
result = timeout(Duration::from_millis(timeout_ms), lines.next_line()) => { |
|
match result { |
|
Ok(Ok(Some(line))) => { |
|
if let Ok(rec) = serde_json::from_str::<WorkerRecord>(&line) { |
|
if rec.done { |
|
done_received = true; |
|
start_rss_kb = rec.start_rss_kb; |
|
peak_rss_kb = rec.peak_rss_kb; |
|
break; |
|
} |
|
symbol_count += 1; |
|
let _ = tx.send(DbRecord { |
|
file: file_str.clone(), |
|
line: rec.line, |
|
col: rec.col, |
|
symbol: rec.symbol, |
|
hover: rec.hover, |
|
ms: rec.ms, |
|
heap_delta_kb: rec.heap_delta_kb, |
|
instantiations: rec.instantiations, |
|
timed_out: false, |
|
oom: false, |
|
}); |
|
} |
|
} |
|
Ok(Ok(None)) => break, |
|
Ok(Err(_)) => break, |
|
Err(_) => { timed_out = true; break; } |
|
} |
|
} |
|
_ = &mut oom_fut => { rss_oom = true; break; } |
|
} |
|
} |
|
|
|
let _ = child.kill().await; |
|
let _ = child.wait().await; |
|
let duration_ms = t_start.elapsed().as_secs_f64() * 1000.0; |
|
|
|
let v8_oom = !done_received && !timed_out && !rss_oom; |
|
if timed_out || rss_oom || v8_oom { |
|
let label = if timed_out { |
|
"<<TIMEOUT>>" |
|
} else if rss_oom { |
|
"<<RSS_OOM>>" |
|
} else { |
|
"<<V8_OOM>>" |
|
}; |
|
let _ = tx.send(DbRecord { |
|
file: file_str.clone(), |
|
line: 0, |
|
col: 0, |
|
symbol: label.into(), |
|
hover: String::new(), |
|
ms: -1.0, |
|
heap_delta_kb: 0, |
|
instantiations: 0, |
|
timed_out, |
|
oom: rss_oom || v8_oom, |
|
}); |
|
} |
|
|
|
FileStats { |
|
file: file_str, |
|
peak_rss_kb, |
|
start_rss_kb, |
|
duration_ms, |
|
symbol_count, |
|
} |
|
} |
|
|
|
#[tokio::main] |
|
async fn main() -> Result<()> { |
|
let cli = Cli::parse(); |
|
|
|
let workers = cli.workers.unwrap_or_else(|| { |
|
std::thread::available_parallelism() |
|
.map(|n| n.get()) |
|
.unwrap_or(4) |
|
}); |
|
let timeout_ms = cli.timeout_ms; |
|
let max_rss_bytes = cli.max_rss_mb * 1_000_000; |
|
|
|
let root = cli.root.canonicalize()?; |
|
let tsconfig = root.join("tsconfig.json"); |
|
if !tsconfig.exists() { |
|
bail!("tsconfig.json not found in {:?}", root); |
|
} |
|
|
|
let format = cli.format.clone(); |
|
let output = cli |
|
.output |
|
.clone() |
|
.unwrap_or_else(|| default_output_path(&root)); |
|
|
|
// Write embedded worker to a temp file; keep handle alive for the duration of the run. |
|
let mut _worker_temp = tempfile::Builder::new() |
|
.prefix("type-profiler-worker-") |
|
.suffix(".cjs") |
|
.tempfile()?; |
|
_worker_temp.write_all(WORKER_SRC.as_bytes())?; |
|
let worker_js = _worker_temp.path().to_path_buf(); |
|
|
|
if cli.incremental && cli.rerun_worst.is_some() { |
|
bail!("--incremental and --rerun-worst are mutually exclusive"); |
|
} |
|
|
|
// Snapshot previous-run instantiation counts before any table setup (which may drop them). |
|
let prev_max_inst: std::collections::HashMap<String, u64> = |
|
if cli.threshold.is_some() && output.exists() { |
|
let conn = Connection::open(&output)?; |
|
let mut stmt = conn |
|
.prepare("SELECT file, MAX(instantiations) FROM results GROUP BY file") |
|
.unwrap_or_else(|_| conn.prepare("SELECT '' as file, 0 as m WHERE 0").unwrap()); |
|
stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))) |
|
.unwrap_or_else(|_| unreachable!()) |
|
.filter_map(|r| r.ok()) |
|
.map(|(f, inst)| (f, inst as u64)) |
|
.collect() |
|
} else { |
|
std::collections::HashMap::new() |
|
}; |
|
|
|
// Always collect into SQLite, format controls what's printed at the end |
|
let setup_conn = Connection::open(&output)?; |
|
if cli.incremental || cli.rerun_worst.is_some() { |
|
setup_conn.execute_batch( |
|
" |
|
CREATE TABLE IF NOT EXISTS results ( |
|
file TEXT NOT NULL, line INTEGER NOT NULL, col INTEGER NOT NULL, |
|
symbol TEXT, hover TEXT, ms REAL, |
|
heap_delta_kb INTEGER DEFAULT 0, instantiations INTEGER DEFAULT 0, |
|
timed_out INTEGER DEFAULT 0, oom INTEGER DEFAULT 0 |
|
); |
|
CREATE INDEX IF NOT EXISTS idx_ms ON results (ms DESC); |
|
CREATE INDEX IF NOT EXISTS idx_file ON results (file); |
|
CREATE INDEX IF NOT EXISTS idx_inst ON results (instantiations DESC); |
|
", |
|
)?; |
|
} else { |
|
// Fresh run — drop stale data so results are never duplicated |
|
setup_conn.execute_batch( |
|
" |
|
DROP TABLE IF EXISTS results; |
|
DROP TABLE IF EXISTS file_stats; |
|
CREATE TABLE results ( |
|
file TEXT NOT NULL, line INTEGER NOT NULL, col INTEGER NOT NULL, |
|
symbol TEXT, hover TEXT, ms REAL, |
|
heap_delta_kb INTEGER DEFAULT 0, instantiations INTEGER DEFAULT 0, |
|
timed_out INTEGER DEFAULT 0, oom INTEGER DEFAULT 0 |
|
); |
|
CREATE INDEX idx_ms ON results (ms DESC); |
|
CREATE INDEX idx_file ON results (file); |
|
CREATE INDEX idx_inst ON results (instantiations DESC); |
|
", |
|
)?; |
|
} |
|
drop(setup_conn); |
|
|
|
// Always-excluded directory names |
|
let mut excluded: std::collections::HashSet<String> = ["node_modules", ".claude", ".git"] |
|
.iter() |
|
.map(|s| s.to_string()) |
|
.collect(); |
|
excluded.extend(cli.exclude.into_iter()); |
|
|
|
// Discover .ts/.tsx files |
|
let files: Vec<PathBuf> = if let Some(n) = cli.rerun_worst { |
|
if !output.exists() { |
|
bail!("--rerun-worst requires an existing DB at {:?}", output); |
|
} |
|
let conn = Connection::open(&output)?; |
|
let mut stmt = conn.prepare( |
|
"SELECT DISTINCT file FROM results \ |
|
ORDER BY (SELECT MAX(instantiations) FROM results r2 WHERE r2.file = results.file) DESC \ |
|
LIMIT ?1" |
|
)?; |
|
let selected: Vec<PathBuf> = stmt |
|
.query_map([n], |r| r.get::<_, String>(0))? |
|
.filter_map(|r| r.ok()) |
|
.map(PathBuf::from) |
|
.filter(|p| p.exists()) |
|
.collect(); |
|
eprintln!("--rerun-worst: selected {} files", selected.len()); |
|
for f in &selected { |
|
let f_str = f.to_string_lossy(); |
|
conn.execute("DELETE FROM results WHERE file = ?1", params![f_str])?; |
|
conn.execute("DELETE FROM file_stats WHERE file = ?1", params![f_str])?; |
|
} |
|
selected |
|
} else { |
|
let mut fs: Vec<PathBuf> = ignore::WalkBuilder::new(&root) |
|
.standard_filters(true) |
|
.require_git(false) // respect .gitignore even outside git repos (monorepos, temp dirs) |
|
.hidden(false) |
|
.build() |
|
.filter_map(|e| e.ok()) |
|
.filter(|e| e.file_type().is_some_and(|t| t.is_file())) |
|
.filter(|e| { |
|
let p = e.path(); |
|
let ext = p.extension().and_then(|s| s.to_str()); |
|
matches!(ext, Some("ts") | Some("tsx")) |
|
&& !p |
|
.components() |
|
.any(|c| excluded.contains(c.as_os_str().to_string_lossy().as_ref())) |
|
}) |
|
.map(|e| e.path().to_path_buf()) |
|
.collect(); |
|
|
|
if cli.incremental { |
|
let conn = Connection::open(&output)?; |
|
let mut stmt = conn.prepare("SELECT DISTINCT file FROM results")?; |
|
let done: std::collections::HashSet<String> = stmt |
|
.query_map([], |r| r.get(0))? |
|
.filter_map(|r| r.ok()) |
|
.collect(); |
|
fs.retain(|f| !done.contains(f.to_string_lossy().as_ref())); |
|
} |
|
|
|
if let Some(threshold) = cli.threshold { |
|
let before = fs.len(); |
|
fs.retain(|f| { |
|
let key = f.to_string_lossy(); |
|
match prev_max_inst.get(key.as_ref()) { |
|
Some(&max_inst) => max_inst >= threshold, |
|
None => true, |
|
} |
|
}); |
|
let skipped = before - fs.len(); |
|
if skipped > 0 { |
|
eprintln!("--threshold {threshold}: skipped {skipped} files below threshold"); |
|
} |
|
} |
|
|
|
fs |
|
}; |
|
|
|
let total = files.len(); |
|
if total == 0 { |
|
eprintln!("No files to process."); |
|
return Ok(()); |
|
} |
|
eprintln!( |
|
"Files: {total} Workers: {workers} Timeout: {timeout_ms}ms Max RSS: {}MB", |
|
cli.max_rss_mb |
|
); |
|
|
|
// Single blocking writer — rusqlite Connection is !Send |
|
let (tx, rx) = std::sync::mpsc::channel::<DbRecord>(); |
|
let db_path = output.clone(); |
|
let writer = tokio::task::spawn_blocking(move || { |
|
let conn = Connection::open(&db_path).unwrap(); |
|
conn.execute_batch("PRAGMA journal_mode=WAL;").unwrap(); |
|
while let Ok(rec) = rx.recv() { |
|
conn.execute( |
|
"INSERT INTO results \ |
|
(file,line,col,symbol,hover,ms,heap_delta_kb,instantiations,timed_out,oom) \ |
|
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)", |
|
params![ |
|
rec.file, |
|
rec.line, |
|
rec.col, |
|
rec.symbol, |
|
rec.hover, |
|
rec.ms, |
|
rec.heap_delta_kb, |
|
rec.instantiations as i64, |
|
rec.timed_out as i32, |
|
rec.oom as i32 |
|
], |
|
) |
|
.ok(); |
|
} |
|
}); |
|
|
|
let pb = ProgressBar::new(total as u64); |
|
pb.set_style( |
|
ProgressStyle::default_bar() |
|
.template("[{elapsed_precise}] {bar:50} {pos}/{len} ETA {eta} {msg}") |
|
.unwrap() |
|
.progress_chars("=> "), |
|
); |
|
|
|
let sem = Arc::new(Semaphore::new(workers)); |
|
let mut handles = Vec::with_capacity(total); |
|
|
|
for file in files { |
|
let permit = Arc::clone(&sem).acquire_owned().await?; |
|
let tx = tx.clone(); |
|
let wjs = worker_js.clone(); |
|
let tc = find_nearest_tsconfig(&file, &root, &tsconfig); |
|
let pb = pb.clone(); |
|
let fname = file |
|
.file_name() |
|
.unwrap_or_default() |
|
.to_string_lossy() |
|
.into_owned(); |
|
|
|
handles.push(tokio::spawn(async move { |
|
pb.set_message(fname); |
|
let stats = process_file(file, wjs, tc, timeout_ms, max_rss_bytes, tx).await; |
|
pb.inc(1); |
|
drop(permit); |
|
stats |
|
})); |
|
} |
|
|
|
let mut file_stats: Vec<FileStats> = Vec::with_capacity(total); |
|
for h in handles { |
|
if let Ok(stats) = h.await { |
|
file_stats.push(stats); |
|
} |
|
} |
|
drop(tx); |
|
pb.finish_with_message("done"); |
|
writer.await?; |
|
|
|
// Write file-level stats |
|
{ |
|
let stats_conn = Connection::open(&output)?; |
|
stats_conn.execute_batch( |
|
" |
|
CREATE TABLE IF NOT EXISTS file_stats ( |
|
file TEXT PRIMARY KEY, |
|
peak_rss_kb INTEGER DEFAULT 0, |
|
start_rss_kb INTEGER DEFAULT 0, |
|
duration_ms REAL, |
|
symbol_count INTEGER DEFAULT 0 |
|
); |
|
", |
|
)?; |
|
for s in &file_stats { |
|
stats_conn.execute( |
|
"INSERT OR REPLACE INTO file_stats (file,peak_rss_kb,start_rss_kb,duration_ms,symbol_count) \ |
|
VALUES (?1,?2,?3,?4,?5)", |
|
rusqlite::params![s.file, s.peak_rss_kb as i64, s.start_rss_kb as i64, s.duration_ms, s.symbol_count], |
|
)?; |
|
} |
|
} |
|
|
|
// Diff against a previous DB if --compare was supplied |
|
if let Some(compare_path) = &cli.compare { |
|
let compare_abs = compare_path |
|
.canonicalize() |
|
.unwrap_or_else(|_| compare_path.clone()); |
|
let diff_conn = Connection::open(&output)?; |
|
diff_conn.execute_batch(&format!( |
|
"ATTACH DATABASE '{}' AS prev;", |
|
compare_abs.to_string_lossy().replace('\'', "''") |
|
))?; |
|
|
|
let prev_name = compare_abs |
|
.file_name() |
|
.and_then(|s| s.to_str()) |
|
.unwrap_or("previous.db"); |
|
println!("\nDIFF vs {prev_name}"); |
|
println!("{}", "\u{2500}".repeat(80)); |
|
println!( |
|
"{:>13} {:>13} {:>10} sym (file:line)", |
|
"inst_before", "inst_after", "delta" |
|
); |
|
|
|
let mut sym_stmt = diff_conn.prepare( |
|
"SELECT |
|
r.file, |
|
r.line, |
|
substr(r.symbol, 1, 80) as sym, |
|
prev_r.instantiations as inst_before, |
|
r.instantiations as inst_after, |
|
CAST(r.instantiations AS INTEGER) - CAST(prev_r.instantiations AS INTEGER) as inst_delta, |
|
prev_r.ms as ms_before, |
|
r.ms as ms_after, |
|
CAST(r.ms AS REAL) - CAST(prev_r.ms AS REAL) as ms_delta |
|
FROM results r |
|
JOIN prev.results prev_r |
|
ON r.file = prev_r.file |
|
AND substr(r.symbol,1,80) = substr(prev_r.symbol,1,80) |
|
WHERE r.instantiations != prev_r.instantiations |
|
OR r.ms != prev_r.ms |
|
ORDER BY ABS(inst_delta) DESC |
|
LIMIT 40" |
|
)?; |
|
let sym_rows = sym_stmt.query_map([], |r| { |
|
Ok(( |
|
r.get::<_, String>(0)?, |
|
r.get::<_, i64>(1)?, |
|
r.get::<_, String>(2)?, |
|
r.get::<_, i64>(3)?, |
|
r.get::<_, i64>(4)?, |
|
r.get::<_, i64>(5)?, |
|
)) |
|
})?; |
|
for row in sym_rows.filter_map(|r| r.ok()) { |
|
let (file, line, sym, inst_before, inst_after, inst_delta) = row; |
|
let sym_first = sym |
|
.lines() |
|
.next() |
|
.unwrap_or("") |
|
.chars() |
|
.take(80) |
|
.collect::<String>(); |
|
let fname = std::path::Path::new(&file) |
|
.file_name() |
|
.and_then(|s| s.to_str()) |
|
.unwrap_or(&file); |
|
let marker = if inst_delta < 0 { |
|
" \u{2713} improved" |
|
} else if inst_delta > 0 { |
|
" \u{26a0} regression" |
|
} else { |
|
"" |
|
}; |
|
println!( |
|
"{:>13} {:>13} {:>+10} {} ({}:{}){}", |
|
inst_before, inst_after, inst_delta, sym_first, fname, line, marker |
|
); |
|
} |
|
|
|
println!( |
|
"\n{:>12} {:>12} {:>10} file", |
|
"rss_before", "rss_after", "delta" |
|
); |
|
println!("{}", "\u{2500}".repeat(80)); |
|
let mut rss_stmt = diff_conn.prepare( |
|
"SELECT |
|
fs.file, |
|
prev_fs.peak_rss_kb as rss_before, |
|
fs.peak_rss_kb as rss_after, |
|
CAST(fs.peak_rss_kb AS INTEGER) - CAST(prev_fs.peak_rss_kb AS INTEGER) as rss_delta |
|
FROM file_stats fs |
|
JOIN prev.file_stats prev_fs ON fs.file = prev_fs.file |
|
WHERE ABS(fs.peak_rss_kb - prev_fs.peak_rss_kb) > 1024 |
|
ORDER BY ABS(rss_delta) DESC |
|
LIMIT 20", |
|
)?; |
|
let rss_rows = rss_stmt.query_map([], |r| { |
|
Ok(( |
|
r.get::<_, String>(0)?, |
|
r.get::<_, i64>(1)?, |
|
r.get::<_, i64>(2)?, |
|
r.get::<_, i64>(3)?, |
|
)) |
|
})?; |
|
let rss_rows: Vec<_> = rss_rows.filter_map(|r| r.ok()).collect(); |
|
if rss_rows.is_empty() { |
|
println!(" (no file-level RSS changes > 1 MB)"); |
|
} else { |
|
for (file, rss_before, rss_after, rss_delta) in &rss_rows { |
|
let fname = std::path::Path::new(file) |
|
.file_name() |
|
.and_then(|s| s.to_str()) |
|
.unwrap_or(file); |
|
println!( |
|
"{:>10}MB {:>10}MB {:>+8}MB {}", |
|
rss_before / 1024, |
|
rss_after / 1024, |
|
rss_delta / 1024, |
|
fname |
|
); |
|
} |
|
} |
|
} |
|
|
|
// Read results back from SQLite and format |
|
let out_conn = Connection::open(&output)?; |
|
match format.as_str() { |
|
"sqlite" => { |
|
eprintln!( |
|
"\nSaved → {}\n\nBy instantiations (explosions):\n sqlite3 {} 'SELECT file,line,substr(symbol,1,60),instantiations,ms FROM results ORDER BY instantiations DESC LIMIT 30'\n\nBy time:\n sqlite3 {} 'SELECT file,line,substr(symbol,1,60),ms FROM results WHERE ms > 0 ORDER BY ms DESC LIMIT 30'", |
|
output.display(), output.display(), output.display() |
|
); |
|
} |
|
"json" => { |
|
let mut stmt = out_conn.prepare( |
|
"SELECT file,line,col,symbol,hover,ms,heap_delta_kb,instantiations,timed_out,oom \ |
|
FROM results ORDER BY instantiations DESC", |
|
)?; |
|
let rows = stmt.query_map([], |r| { |
|
Ok(( |
|
r.get::<_, String>(0)?, |
|
r.get::<_, u32>(1)?, |
|
r.get::<_, u32>(2)?, |
|
r.get::<_, String>(3)?, |
|
r.get::<_, String>(4)?, |
|
r.get::<_, f64>(5)?, |
|
r.get::<_, i64>(6)?, |
|
r.get::<_, i64>(7)?, |
|
r.get::<_, i32>(8)?, |
|
r.get::<_, i32>(9)?, |
|
)) |
|
})?; |
|
for row in rows.filter_map(|r| r.ok()) { |
|
println!( |
|
"{}", |
|
serde_json::json!({ |
|
"file": row.0, "line": row.1, "col": row.2, |
|
"symbol": row.3, "hover": row.4, |
|
"ms": row.5, "rssDeltaKb": row.6, "instantiations": row.7, |
|
"timedOut": row.8 != 0, "oom": row.9 != 0, |
|
}) |
|
); |
|
} |
|
// File stats as a second stream |
|
let mut fstmt = out_conn.prepare( |
|
"SELECT file,peak_rss_kb,start_rss_kb,duration_ms,symbol_count FROM file_stats", |
|
)?; |
|
let frows = fstmt.query_map([], |r| { |
|
Ok(( |
|
r.get::<_, String>(0)?, |
|
r.get::<_, i64>(1)?, |
|
r.get::<_, i64>(2)?, |
|
r.get::<_, f64>(3)?, |
|
r.get::<_, i64>(4)?, |
|
)) |
|
})?; |
|
for row in frows.filter_map(|r| r.ok()) { |
|
println!( |
|
"{}", |
|
serde_json::json!({ |
|
"type": "file_stats", |
|
"file": row.0, "peakRssKb": row.1, "startRssKb": row.2, |
|
"durationMs": row.3, "symbolCount": row.4, |
|
}) |
|
); |
|
} |
|
} |
|
"csv" => { |
|
println!("file,line,col,ms,instantiations,heap_delta_kb,timed_out,oom,symbol"); |
|
let mut stmt = out_conn.prepare( |
|
"SELECT file,line,col,ms,instantiations,heap_delta_kb,timed_out,oom,symbol \ |
|
FROM results ORDER BY instantiations DESC", |
|
)?; |
|
let rows = stmt.query_map([], |r| { |
|
Ok(( |
|
r.get::<_, String>(0)?, |
|
r.get::<_, u32>(1)?, |
|
r.get::<_, u32>(2)?, |
|
r.get::<_, f64>(3)?, |
|
r.get::<_, i64>(4)?, |
|
r.get::<_, i64>(5)?, |
|
r.get::<_, i32>(6)?, |
|
r.get::<_, i32>(7)?, |
|
r.get::<_, String>(8)?, |
|
)) |
|
})?; |
|
for row in rows.filter_map(|r| r.ok()) { |
|
let sym = row.8.lines().next().unwrap_or("").replace(',', ";"); |
|
println!( |
|
"{},{},{},{},{},{},{},{},{}", |
|
row.0, row.1, row.2, row.3, row.4, row.5, row.6, row.7, sym |
|
); |
|
} |
|
} |
|
_ => { |
|
// Per-type table: top 50 by instantiations |
|
let mut stmt = out_conn.prepare( |
|
"SELECT file,line,ms,instantiations,heap_delta_kb,symbol \ |
|
FROM results WHERE ms > 0 ORDER BY instantiations DESC LIMIT 50", |
|
)?; |
|
let rows = stmt.query_map([], |r| { |
|
Ok(( |
|
r.get::<_, String>(0)?, |
|
r.get::<_, u32>(1)?, |
|
r.get::<_, f64>(2)?, |
|
r.get::<_, i64>(3)?, |
|
r.get::<_, i64>(4)?, |
|
r.get::<_, String>(5)?, |
|
)) |
|
})?; |
|
println!( |
|
"{:>9} {:>8} {:>8} file:line symbol", |
|
"ms", "inst", "rss_kb" |
|
); |
|
println!("{}", "-".repeat(80)); |
|
for row in rows.filter_map(|r| r.ok()) { |
|
let sym = row |
|
.5 |
|
.lines() |
|
.next() |
|
.unwrap_or("") |
|
.chars() |
|
.take(60) |
|
.collect::<String>(); |
|
println!( |
|
"{:>9.1} {:>8} {:>+7}kb {}:{} {}", |
|
row.2, row.3, row.4, row.0, row.1, sym |
|
); |
|
} |
|
|
|
// Per-file summary: peak RSS and duration |
|
let mut fstmt = out_conn.prepare( |
|
"SELECT file, peak_rss_kb, start_rss_kb, duration_ms, symbol_count \ |
|
FROM file_stats WHERE peak_rss_kb > 0 ORDER BY peak_rss_kb DESC LIMIT 30", |
|
)?; |
|
let frows = fstmt.query_map([], |r| { |
|
Ok(( |
|
r.get::<_, String>(0)?, |
|
r.get::<_, i64>(1)?, |
|
r.get::<_, i64>(2)?, |
|
r.get::<_, f64>(3)?, |
|
r.get::<_, i64>(4)?, |
|
)) |
|
}); |
|
if let Ok(frows) = frows { |
|
let frows: Vec<_> = frows.filter_map(|r| r.ok()).collect(); |
|
if !frows.is_empty() { |
|
println!( |
|
"\n{:>10} {:>9} {:>9} {:>6} file", |
|
"peak_rss", "start_rss", "delta_rss", "syms" |
|
); |
|
println!("{}", "-".repeat(80)); |
|
for row in &frows { |
|
let fname = std::path::Path::new(&row.0) |
|
.file_name() |
|
.and_then(|s| s.to_str()) |
|
.unwrap_or(&row.0); |
|
let delta = row.1 - row.2; |
|
println!( |
|
"{:>8}MB {:>7}MB {:>+8}MB {:>6} {}", |
|
row.1 / 1024, |
|
row.2 / 1024, |
|
delta / 1024, |
|
row.4, |
|
fname |
|
); |
|
} |
|
} |
|
} |
|
|
|
// Unique types aggregated across all files |
|
let flagged_substrings = [ |
|
"DeepKey<", |
|
"SyncPair<", |
|
"PathsWithSameValueAs<", |
|
"DeepKeyFiltered<", |
|
"ArrayOfChanges<", |
|
"ComputationPair<", |
|
"AggregationPair<", |
|
"FlipPair<", |
|
]; |
|
let mut ustmt = out_conn.prepare( |
|
"SELECT \ |
|
substr(symbol, 1, 80) AS type_expr, \ |
|
COUNT(DISTINCT file) AS file_count, \ |
|
SUM(instantiations) AS total_inst, \ |
|
ROUND(SUM(ms), 1) AS total_ms, \ |
|
MAX(heap_delta_kb) AS max_rss_kb, \ |
|
MIN(file) || ':' || MIN(line) AS example_loc \ |
|
FROM results \ |
|
WHERE ms > 0 AND instantiations > 100 \ |
|
GROUP BY substr(symbol, 1, 80) \ |
|
HAVING COUNT(DISTINCT file) > 0 \ |
|
ORDER BY SUM(instantiations) DESC \ |
|
LIMIT 30", |
|
)?; |
|
let urows: Vec<(String, i64, i64, f64, i64, String)> = ustmt |
|
.query_map([], |r| { |
|
Ok(( |
|
r.get::<_, String>(0)?, |
|
r.get::<_, i64>(1)?, |
|
r.get::<_, i64>(2)?, |
|
r.get::<_, f64>(3)?, |
|
r.get::<_, i64>(4)?, |
|
r.get::<_, String>(5)?, |
|
)) |
|
})? |
|
.filter_map(|r| r.ok()) |
|
.collect(); |
|
if !urows.is_empty() { |
|
println!("\nUNIQUE TYPES (by total instantiations across all files)"); |
|
println!("{}", "\u{2500}".repeat(80)); |
|
println!( |
|
"{:>12} {:>5} {:>10} flag type_expr", |
|
"total_inst", "files", "total_ms" |
|
); |
|
for (type_expr, file_count, total_inst, total_ms, _max_rss_kb, _example_loc) in |
|
&urows |
|
{ |
|
let flag = if flagged_substrings.iter().any(|s| type_expr.contains(s)) { |
|
"[!]" |
|
} else { |
|
" " |
|
}; |
|
let sym = type_expr |
|
.lines() |
|
.next() |
|
.unwrap_or("") |
|
.chars() |
|
.take(60) |
|
.collect::<String>(); |
|
println!( |
|
"{:>12} {:>5} {:>8.0}ms {} {}", |
|
total_inst, file_count, total_ms, flag, sym |
|
); |
|
} |
|
} |
|
|
|
// Directory rollup |
|
let mut dstmt = out_conn.prepare( |
|
"SELECT \ |
|
rtrim(file, replace(file, '/', '')) AS dir, \ |
|
COUNT(DISTINCT file) AS file_count, \ |
|
SUM(instantiations) AS total_inst, \ |
|
ROUND(SUM(ms), 1) AS total_ms, \ |
|
MAX(fs.peak_rss_kb) AS peak_rss_kb \ |
|
FROM results \ |
|
LEFT JOIN file_stats fs USING (file) \ |
|
GROUP BY dir \ |
|
ORDER BY SUM(instantiations) DESC \ |
|
LIMIT 20", |
|
)?; |
|
let drows: Vec<(String, i64, i64, f64, i64)> = dstmt |
|
.query_map([], |r| { |
|
Ok(( |
|
r.get::<_, String>(0)?, |
|
r.get::<_, i64>(1)?, |
|
r.get::<_, i64>(2)?, |
|
r.get::<_, f64>(3)?, |
|
r.get::<_, i64>(4)?, |
|
)) |
|
})? |
|
.filter_map(|r| r.ok()) |
|
.collect(); |
|
if !drows.is_empty() { |
|
let common_prefix: String = { |
|
let first = &drows[0].0; |
|
let prefix_len = first |
|
.char_indices() |
|
.find(|(i, c)| drows.iter().any(|(d, ..)| d.chars().nth(*i) != Some(*c))) |
|
.map(|(i, _)| i) |
|
.unwrap_or(first.len()); |
|
let prefix = &first[..prefix_len]; |
|
match prefix.rfind('/') { |
|
Some(p) => prefix[..=p].to_string(), |
|
None => String::new(), |
|
} |
|
}; |
|
println!("\nDIRECTORY ROLLUP (by total instantiations)"); |
|
println!("{}", "\u{2500}".repeat(80)); |
|
println!( |
|
"{:>12} {:>5} {:>10} {:>9} directory", |
|
"total_inst", "files", "total_ms", "peak_rss" |
|
); |
|
for (dir, file_count, total_inst, total_ms, peak_rss_kb) in &drows { |
|
let short_dir = dir |
|
.strip_prefix(&common_prefix) |
|
.map(|s| format!(".../{s}")) |
|
.unwrap_or_else(|| dir.clone()); |
|
let peak_mb = peak_rss_kb / 1024; |
|
println!( |
|
"{:>12} {:>5} {:>8.0}ms {:>7}MB {}", |
|
total_inst, file_count, total_ms, peak_mb, short_dir |
|
); |
|
} |
|
} |
|
} |
|
} |
|
|
|
Ok(()) |
|
} |