diff --git a/Cargo.lock b/Cargo.lock index 91e977c..3b76153 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -881,6 +881,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "strsim" version = "0.11.1" @@ -1031,6 +1037,7 @@ dependencies = [ "regex", "serde", "serde_json", + "similar", "syntect", "tempfile", "tiny_http", diff --git a/Cargo.toml b/Cargo.toml index 44bb7eb..0806ba2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ opener = "0.6.1" regex = "1.9.2" serde = { version = "1.0.185", features = ["serde_derive"] } serde_json = "1.0.100" +similar = "2" tinytemplate = "1.1.0" tiny_http = "0.12" diff --git a/src/lib.rs b/src/lib.rs index f147837..69c1673 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -534,6 +534,7 @@ pub fn parse_path(path: &PathBuf, config: &ParseConfig) -> anyhow::Result = FxHashSet::default(); diff --git a/src/vllm/parsers.rs b/src/vllm/parsers.rs index eb0e2ee..61fd497 100644 --- a/src/vllm/parsers.rs +++ b/src/vllm/parsers.rs @@ -3,11 +3,12 @@ use crate::templates::TEMPLATE_QUERY_PARAM_SCRIPT; use crate::types::{CompileId, Envelope}; use super::types::{ - ArtifactInfo, VllmCompilationConfig, VllmCompileRangeGroup, VllmSubgraphInfo, + ArtifactInfo, VllmCompilationConfig, VllmCompileRangeGroup, VllmDiffContext, VllmSubgraphInfo, VllmSubgraphWithArtifacts, VllmSummaryContext, }; use std::cell::RefCell; +use std::fmt::Write as FmtWrite; use std::rc::Rc; use tinytemplate::TinyTemplate; @@ -250,11 +251,239 @@ impl StructuredLogParser for VllmPiecewiseSplitGraphParser { } } +// Parses two kinds of log entries to produce per-pass diff pages: +// +// 1. "before_post_grad_graph" artifact — the graph before any passes run. +// Stored as the diff baseline; no file output (ArtifactParser handles that). +// +// 2. "vllm_post_grad.." graph dump — the graph after a pass. +// Diffed against `previous_payload` to produce a side-by-side HTML diff, +// then becomes the new baseline for the next pass. +pub struct VllmPostGradPassDiffParser { + state: Rc, + // The graph payload from the previous pass (or before_post_grad_graph), + // used as the "before" side of the next diff. + previous_payload: RefCell>, +} + +impl VllmPostGradPassDiffParser { + pub fn new(state: Rc) -> Self { + Self { + state, + previous_payload: RefCell::new(None), + } + } + + // Build a side-by-side diff HTML table from two text payloads. + // + // Uses `similar` to compute a unified diff, then renders each hunk as a + // 4-column table: [old line num | old code | new line num | new code]. + // + // For changed regions, consecutive Delete and Insert lines are collected + // and paired row-by-row so additions appear next to the deletions they + // replaced (like GitHub's side-by-side view). + fn generate_diff_html(before: &str, after: &str) -> String { + use similar::{ChangeTag, TextDiff}; + + let diff = TextDiff::from_lines(before, after); + + let mut html = String::new(); + let mut has_hunks = false; + + for hunk in diff.unified_diff().context_radius(3).iter_hunks() { + // Emit table header on first hunk + if !has_hunks { + html.push_str("
\n\n"); + has_hunks = true; + } + + let _ = write!( + html, + "\n", + html_escape::encode_text(&hunk.header().to_string()), + ); + + // Walk changes, grouping consecutive deletes+inserts for side-by-side pairing + let changes: Vec<_> = hunk.iter_changes().collect(); + let mut i = 0; + while i < changes.len() { + match changes[i].tag() { + // Unchanged context line — show on both sides + ChangeTag::Equal => { + let text = + html_escape::encode_text(changes[i].value().trim_end_matches('\n')); + let old_line = changes[i].old_index().map(|n| n + 1); + let new_line = changes[i].new_index().map(|n| n + 1); + let _ = write!( + html, + "\n", + old_line.map(|n| n.to_string()).unwrap_or_default(), + text, + new_line.map(|n| n.to_string()).unwrap_or_default(), + text, + ); + i += 1; + } + // Deletion — collect consecutive deletes, then consecutive inserts, + // and pair them row-by-row (excess on either side gets blank cells) + ChangeTag::Delete => { + let mut deletes = Vec::new(); + while i < changes.len() && changes[i].tag() == ChangeTag::Delete { + deletes.push(&changes[i]); + i += 1; + } + let mut inserts = Vec::new(); + while i < changes.len() && changes[i].tag() == ChangeTag::Insert { + inserts.push(&changes[i]); + i += 1; + } + let max_len = deletes.len().max(inserts.len()); + for j in 0..max_len { + let (left_class, left_num, left_text) = if j < deletes.len() { + let num = deletes[j].old_index().map(|n| n + 1); + let text = html_escape::encode_text( + deletes[j].value().trim_end_matches('\n'), + ); + (" diff-del", num, text.to_string()) + } else { + ("", None, String::new()) + }; + let (right_class, right_num, right_text) = if j < inserts.len() { + let num = inserts[j].new_index().map(|n| n + 1); + let text = html_escape::encode_text( + inserts[j].value().trim_end_matches('\n'), + ); + (" diff-add", num, text.to_string()) + } else { + ("", None, String::new()) + }; + let _ = write!( + html, + "\n", + left_num.map(|n| n.to_string()).unwrap_or_default(), + left_class, + left_text, + right_num.map(|n| n.to_string()).unwrap_or_default(), + right_class, + right_text, + ); + } + } + // Standalone insert (not preceded by a delete) + ChangeTag::Insert => { + let text = + html_escape::encode_text(changes[i].value().trim_end_matches('\n')); + let new_line = changes[i].new_index().map(|n| n + 1); + let _ = write!( + html, + "\n", + new_line.map(|n| n.to_string()).unwrap_or_default(), + text, + ); + i += 1; + } + } + } + } + + if has_hunks { + html.push_str("
@@ {} @@
{}{}{}{}
{}{}{}{}
{}{}
\n"); + } else { + html = "
(no changes)
\n".to_string(); + } + + html + } +} + +impl StructuredLogParser for VllmPostGradPassDiffParser { + fn name(&self) -> &'static str { + "vllm_post_grad_pass_diff" + } + + fn get_metadata<'e>(&self, e: &'e Envelope) -> Option> { + if let Some(graph_dump) = &e.graph_dump { + if graph_dump.name.starts_with("vllm_post_grad.") { + return Some(Metadata::GraphDump(graph_dump)); + } + } + if let Some(artifact) = &e.artifact { + if artifact.name == "before_post_grad_graph" { + return Some(Metadata::Artifact(artifact)); + } + } + None + } + + fn parse<'e>( + &self, + lineno: usize, + metadata: Metadata<'e>, + _rank: Option, + compile_id: &Option, + payload: &str, + ) -> anyhow::Result { + // before_post_grad_graph (artifact): seed baseline for first pass diff. + // Don't output a file — the default ArtifactParser handles that. + if matches!(metadata, Metadata::Artifact(a) if a.name == "before_post_grad_graph") { + *self.previous_payload.borrow_mut() = Some(payload.to_string()); + return Ok(Vec::new()); + } + + let graph_dump = match metadata { + Metadata::GraphDump(gd) => gd, + _ => return Ok(Vec::new()), + }; + + *self.state.has_vllm_artifacts.borrow_mut() = true; + + // e.g. "vllm_post_grad.0.FusionPass" -> pass_name = "0.FusionPass" + let pass_name = graph_dump + .name + .strip_prefix("vllm_post_grad.") + .unwrap_or(&graph_dump.name); + + // Always output the raw post-pass graph as a .txt file + let txt_filename = format!("{}.txt", graph_dump.name); + let txt_path = build_file_path(&txt_filename, lineno, compile_id); + let mut results: ParserResults = vec![ParserOutput::PayloadFile(txt_path)]; + + // If we have a baseline, generate a side-by-side diff page + let prev = self.previous_payload.borrow().clone(); + if let Some(before) = prev { + let diff_html = Self::generate_diff_html(&before, payload); + + let context = VllmDiffContext { + css: super::templates::VLLM_CSS.to_string(), + pass_name: pass_name.to_string(), + diff_html, + qps: TEMPLATE_QUERY_PARAM_SCRIPT.to_string(), + }; + + let mut tt = TinyTemplate::new(); + tt.add_formatter("format_unescaped", tinytemplate::format_unescaped); + tt.add_template("vllm_diff.html", super::templates::VLLM_DIFF_TEMPLATE)?; + let rendered = tt.render("vllm_diff.html", &context)?; + + let diff_filename = format!("vllm_post_grad.{}.diff.html", pass_name); + let diff_path = build_file_path(&diff_filename, lineno, compile_id); + + results.push(ParserOutput::GlobalFile(diff_path, rendered)); + } + + // This pass's output becomes the baseline for the next pass's diff + *self.previous_payload.borrow_mut() = Some(payload.to_string()); + + Ok(results) + } +} + pub fn vllm_parsers_with_state(state: Rc) -> Vec> { vec![ Box::new(VllmCompilationConfigParser::new(state.clone())), Box::new(VllmPiecewiseSplitGraphParser::new(state.clone())), Box::new(VllmPiecewiseCompileParser::new(state.clone())), + Box::new(VllmPostGradPassDiffParser::new(state.clone())), ] } diff --git a/src/vllm/templates.rs b/src/vllm/templates.rs index 25135e6..3a41a17 100644 --- a/src/vllm/templates.rs +++ b/src/vllm/templates.rs @@ -133,6 +133,80 @@ h3 { } "#; +pub const VLLM_DIFF_TEMPLATE: &str = r#" + + + + Pass Diff: {pass_name} + + + +

Pass Diff: {pass_name}

+{diff_html | format_unescaped} +{qps | format_unescaped} + + +"#; + pub const VLLM_SUMMARY_TEMPLATE: &str = r#" diff --git a/src/vllm/types.rs b/src/vllm/types.rs index 8927d5d..3dbdfc9 100644 --- a/src/vllm/types.rs +++ b/src/vllm/types.rs @@ -68,6 +68,14 @@ pub struct VllmSummaryContext { pub compile_range_groups: Vec, } +#[derive(Debug, Serialize)] +pub struct VllmDiffContext { + pub css: String, + pub pass_name: String, + pub diff_html: String, + pub qps: String, +} + #[derive(Debug, Clone, Serialize)] pub struct VllmSubgraphWithArtifacts { pub submod_name: String, diff --git a/tests/inputs/vllm_post_grad_diff.log b/tests/inputs/vllm_post_grad_diff.log new file mode 100644 index 0000000..e679eaf --- /dev/null +++ b/tests/inputs/vllm_post_grad_diff.log @@ -0,0 +1,14 @@ +V0127 17:17:45.175000 1175001 torch/foo.py:100] {"artifact": {"name": "before_post_grad_graph", "encoding": "string"}, "rank": 0, "frame_id": 0, "frame_compile_id": 0, "attempt": 0, "has_payload": "e830dc536dd44eda7a0b9e5b2440b620"} + def forward(self, x): + a = torch.sin(x) + b = torch.cos(x) + return a + b +V0127 17:17:45.275000 1175001 torch/foo.py:200] {"graph_dump": {"name": "vllm_post_grad.0.FusionPass"}, "rank": 0, "frame_id": 0, "frame_compile_id": 0, "attempt": 0, "has_payload": "10a31f5a0aed872193545914468c4662"} + def forward(self, x): + fused_sincos = torch.ops.fused_sincos(x) + return fused_sincos +V0127 17:17:45.475000 1175001 torch/foo.py:400] {"graph_dump": {"name": "vllm_post_grad.1.ReshapePass"}, "rank": 0, "frame_id": 0, "frame_compile_id": 0, "attempt": 0, "has_payload": "98779deb5e4138055f082b7a744f1c6a"} + def forward(self, x): + fused_sincos = torch.ops.fused_sincos(x) + y = fused_sincos.reshape(-1) + return y diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 03f58cb..b07883f 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -2731,3 +2731,39 @@ fn test_parse_vllm_sample() { assert!(index_html.contains("submod_0"),); assert!(index_html.contains("submod_2"),); } + +#[test] +fn test_parse_vllm_post_grad_diff() { + let path = Path::new("tests/inputs/vllm_post_grad_diff.log").to_path_buf(); + let config = tlparse::ParseConfig { + strict: true, + ..Default::default() + }; + let output = tlparse::parse_path(&path, &config); + assert!(output.is_ok()); + let map: HashMap = output.unwrap().into_iter().collect(); + + // Check post-pass graph txt files exist + assert!(prefix_exists(&map, "-_0_0_0/vllm_post_grad.0.FusionPass")); + assert!(prefix_exists(&map, "-_0_0_0/vllm_post_grad.1.ReshapePass")); + + // First pass diffs against before_post_grad_graph + let fusion_diff_path = PathBuf::from("-_0_0_0/vllm_post_grad.0.FusionPass.diff.html"); + assert!( + map.contains_key(&fusion_diff_path), + "FusionPass diff not found" + ); + let fusion_diff = &map[&fusion_diff_path]; + assert!(fusion_diff.contains("Pass Diff: 0.FusionPass")); + assert!(fusion_diff.contains("diff-add")); + assert!(fusion_diff.contains("diff-del")); + + // Second pass diffs against first pass + let reshape_diff_path = PathBuf::from("-_0_0_0/vllm_post_grad.1.ReshapePass.diff.html"); + assert!( + map.contains_key(&reshape_diff_path), + "ReshapePass diff not found" + ); + let reshape_diff = &map[&reshape_diff_path]; + assert!(reshape_diff.contains("Pass Diff: 1.ReshapePass")); +}