Skip to content

Commit eecebaf

Browse files
committed
perf(graph): top-K out-edges per source cap (default K=64)
Pathological code repos (django, mui) emit 3M+ raw edges before hub-suppression even with similarity/history skipped. Source of the blowup is dense semantic edges from utility files importing into hundreds of modules. PPR push then iterates over all those edges, making scoring p99 ~187 seconds. Cap each source node to the top-K outgoing edges by weight after hub_suppression. K=64 preserves typical 30-50 edges/node while clamping pathological hubs. On treemapper itself: 3M -> 363k edges (88% reduction), scoring 8s -> 3.5s. K is env-tunable via DIFFCTX_MAX_EDGES_PER_NODE. Cap stats (edges_before_cap, edges_dropped_by_cap, nodes_capped, K applied) surface through LatencyBreakdown so calibration runs can quantify how often the cap fires per benchmark. Order matters: cap runs AFTER apply_hub_suppression so suppression's IDF damping sees the true in-degree distribution.
1 parent 3000abd commit eecebaf

4 files changed

Lines changed: 110 additions & 0 deletions

File tree

diffctx/src/graph.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,32 @@ pub struct CsrGraph {
6464
pub idx_to_node: Vec<FragmentId>,
6565
}
6666

67+
/// Statistics from the per-source out-edge cap that runs in
68+
/// `build_graph` after `apply_hub_suppression`. Surfaced to Python
69+
/// via `LatencyBreakdown` so calibration runs can quantify how often
70+
/// the cap fires and how many edges it discards.
71+
#[derive(Default, Clone, Copy)]
72+
pub struct EdgeCapStats {
73+
/// Edge count after merge + hub suppression, before cap.
74+
pub edges_before_cap: usize,
75+
/// Edge count after cap.
76+
pub edges_after_cap: usize,
77+
/// Edges discarded by the cap (lowest-weight neighbors of overfull nodes).
78+
pub edges_dropped_by_cap: usize,
79+
/// Number of source nodes that had > `max_per_node` outgoing edges
80+
/// and therefore had their neighbor list truncated.
81+
pub nodes_capped: usize,
82+
/// The `max_per_node` value actually applied (after env-var override).
83+
pub max_out_edges_per_node: usize,
84+
}
85+
6786
pub struct Graph {
6887
nodes: FxHashSet<FragmentId>,
6988
fwd: FxHashMap<FragmentId, FxHashMap<FragmentId, f64>>,
7089
rev: FxHashMap<FragmentId, FxHashMap<FragmentId, f64>>,
7190
pub edge_categories: FxHashMap<(FragmentId, FragmentId), EdgeCategory>,
7291
csr_cache: Option<(CsrGraph, CsrGraph)>,
92+
pub cap_stats: EdgeCapStats,
7393
}
7494

7595
impl Graph {
@@ -80,6 +100,7 @@ impl Graph {
80100
rev: FxHashMap::default(),
81101
edge_categories: FxHashMap::default(),
82102
csr_cache: None,
103+
cap_stats: EdgeCapStats::default(),
83104
}
84105
}
85106

@@ -419,6 +440,62 @@ fn apply_hub_suppression(
419440
}
420441
}
421442

443+
/// Default top-K out-edges per source node. Calibrated against the
444+
/// observed edge density distribution: typical Python file emits
445+
/// 5-20 semantic + 2-5 structural + ≤20 sibling edges (~30-50 normal),
446+
/// so K=64 preserves all legitimate edges while clamping pathological
447+
/// dense nodes (e.g. utility hubs in django/material-ui that radiate
448+
/// into thousands of dependents).
449+
const DEFAULT_MAX_OUT_EDGES_PER_NODE: usize = 64;
450+
451+
/// Truncate each node's outgoing edge list to the top-K by weight.
452+
/// Run AFTER `apply_hub_suppression` so the suppression pass sees
453+
/// the true in-degree distribution; otherwise its IDF damping is
454+
/// computed against a graph that has already been thinned.
455+
///
456+
/// Returns the cap stats for diagnostic surfacing into `LatencyBreakdown`.
457+
fn cap_out_edges_per_source(
458+
edges: &mut FxHashMap<(FragmentId, FragmentId), f64>,
459+
max_per_node: usize,
460+
) -> EdgeCapStats {
461+
let edges_before = edges.len();
462+
463+
let mut by_src: FxHashMap<FragmentId, Vec<(FragmentId, f64)>> = FxHashMap::default();
464+
for ((src, dst), w) in edges.drain() {
465+
by_src.entry(src).or_default().push((dst, w));
466+
}
467+
468+
let mut nodes_capped = 0;
469+
let mut edges_dropped = 0;
470+
for (src, mut neighbors) in by_src {
471+
if neighbors.len() > max_per_node {
472+
nodes_capped += 1;
473+
edges_dropped += neighbors.len() - max_per_node;
474+
neighbors.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
475+
neighbors.truncate(max_per_node);
476+
}
477+
for (dst, w) in neighbors {
478+
edges.insert((src.clone(), dst), w);
479+
}
480+
}
481+
482+
EdgeCapStats {
483+
edges_before_cap: edges_before,
484+
edges_after_cap: edges.len(),
485+
edges_dropped_by_cap: edges_dropped,
486+
nodes_capped,
487+
max_out_edges_per_node: max_per_node,
488+
}
489+
}
490+
491+
fn read_max_out_edges_per_node() -> usize {
492+
std::env::var("DIFFCTX_MAX_EDGES_PER_NODE")
493+
.ok()
494+
.and_then(|v| v.parse::<usize>().ok())
495+
.filter(|&v| v > 0)
496+
.unwrap_or(DEFAULT_MAX_OUT_EDGES_PER_NODE)
497+
}
498+
422499
pub fn build_graph(
423500
fragments: &[Fragment],
424501
mut edges: FxHashMap<(FragmentId, FragmentId), f64>,
@@ -431,6 +508,17 @@ pub fn build_graph(
431508

432509
apply_hub_suppression(&mut edges, &categories);
433510

511+
let max_per_node = read_max_out_edges_per_node();
512+
let cap_stats = cap_out_edges_per_source(&mut edges, max_per_node);
513+
tracing::debug!(
514+
"edge cap K={}: {} -> {} (dropped {} from {} nodes)",
515+
max_per_node,
516+
cap_stats.edges_before_cap,
517+
cap_stats.edges_after_cap,
518+
cap_stats.edges_dropped_by_cap,
519+
cap_stats.nodes_capped,
520+
);
521+
434522
for ((src, dst), w) in edges {
435523
if w > 0.0 {
436524
graph
@@ -443,6 +531,7 @@ pub fn build_graph(
443531
}
444532

445533
graph.edge_categories = categories;
534+
graph.cap_stats = cap_stats;
446535
graph.freeze();
447536
graph
448537
}

diffctx/src/pipeline.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ pub fn select_with_params(
413413
+ state.heavy_latency_ms.scoring
414414
+ select_ms;
415415

416+
let cap_stats = state.scoring_result.graph.cap_stats;
416417
let mut output = render::build_diff_context_output(&state.root_dir, &selected, no_content);
417418
output.latency = Some(render::LatencyBreakdown {
418419
parse_changed_ms: state.heavy_latency_ms.parse_changed,
@@ -427,6 +428,10 @@ pub fn select_with_params(
427428
candidate_count: state.scoring_result.filtered_fragments.len(),
428429
edge_count: state.scoring_result.graph.edge_count(),
429430
greedy_iters: selection_iters,
431+
edges_before_cap: cap_stats.edges_before_cap,
432+
edges_dropped_by_cap: cap_stats.edges_dropped_by_cap,
433+
nodes_capped: cap_stats.nodes_capped,
434+
max_out_edges_per_node: cap_stats.max_out_edges_per_node,
430435
});
431436
output
432437
}

diffctx/src/pybridge.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,10 @@ fn build_diff_context<'py>(
302302
latency.set_item("candidate_count", lb.candidate_count)?;
303303
latency.set_item("edge_count", lb.edge_count)?;
304304
latency.set_item("greedy_iters", lb.greedy_iters)?;
305+
latency.set_item("edges_before_cap", lb.edges_before_cap)?;
306+
latency.set_item("edges_dropped_by_cap", lb.edges_dropped_by_cap)?;
307+
latency.set_item("nodes_capped", lb.nodes_capped)?;
308+
latency.set_item("max_out_edges_per_node", lb.max_out_edges_per_node)?;
305309
} else {
306310
latency.set_item("total_ms", (total_ms * 10.0).round() / 10.0)?;
307311
}
@@ -416,6 +420,10 @@ fn diff_context_output_to_dict<'py>(
416420
latency.set_item("candidate_count", lb.candidate_count)?;
417421
latency.set_item("edge_count", lb.edge_count)?;
418422
latency.set_item("greedy_iters", lb.greedy_iters)?;
423+
latency.set_item("edges_before_cap", lb.edges_before_cap)?;
424+
latency.set_item("edges_dropped_by_cap", lb.edges_dropped_by_cap)?;
425+
latency.set_item("nodes_capped", lb.nodes_capped)?;
426+
latency.set_item("max_out_edges_per_node", lb.max_out_edges_per_node)?;
419427
}
420428
dict.set_item("latency", latency)?;
421429
Ok(dict)

diffctx/src/render.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ pub struct LatencyBreakdown {
5050
/// fragments). Bounded by `selected.len() - core.len()`. Pairs with
5151
/// `selection_ms` to spot lazy-heap blowup vs. genuine large output.
5252
pub greedy_iters: usize,
53+
/// Edge count after merge + hub suppression, before per-source cap.
54+
pub edges_before_cap: usize,
55+
/// Edges discarded by the per-source top-K cap.
56+
pub edges_dropped_by_cap: usize,
57+
/// Source nodes whose outgoing edge list was truncated by the cap.
58+
pub nodes_capped: usize,
59+
/// The K value applied for the per-source cap.
60+
pub max_out_edges_per_node: usize,
5361
}
5462

5563
#[derive(Serialize, Clone)]

0 commit comments

Comments
 (0)