Refactor MIR graph rendering to use a unified traversal via GraphBuilder#132
Refactor MIR graph rendering to use a unified traversal via GraphBuilder#1320xh4ty wants to merge 17 commits intoruntimeverification:masterfrom
Conversation
|
LGTM! Would you remove the previous implementation before merging? Or just in another PR? @dkcumming what do you think? |
|
Thanks! I’ll remove the legacy D2 implementation in this PR itself. |
| /// Format-agnostic MIR graph traversal. | ||
| /// Owns traversal order and graph semantics, delegates rendering to `GraphBuilder`. | ||
| pub fn render_graph<B: GraphBuilder>(smir: &SmirJson, mut builder: B) -> B::Output { | ||
| let ctx = GraphContext::from_smir(smir); |
There was a problem hiding this comment.
pub fn to_d2_file_new(&self) -> String {
let ctx = GraphContext::from_smir(self);
render_graph(self, D2Builder::new(&ctx))
}It seems not efficient. It builds GraphContext twice.
There was a problem hiding this comment.
Good catch. I’ll move GraphContext construction out of render_graph so it is built only once and passed through.
src/mk_graph/traverse.rs
Outdated
| ) { | ||
| let fn_id = short_name(name); | ||
| let label = name_lines(name); | ||
| let is_local = true; |
There was a problem hiding this comment.
Should be body.is_some() ?
There was a problem hiding this comment.
Yes, that should be body.is_some(). I’ll fix that.
| pub trait GraphBuilder { | ||
| type Output; | ||
|
|
||
| fn begin_graph(&mut self, name: &str); |
There was a problem hiding this comment.
I'd like to have some description here.
There was a problem hiding this comment.
Got it. I’ll expand the doc comment.
dkcumming
left a comment
There was a problem hiding this comment.
@0xh4ty this is great work! I love what you have done so far! I think it's time to go all in and get the old stuff out and hook up the full implementation of d2 and dot (okay if that is another PR if necessary). I think isolating the shared logic out in the traverse module is a great improvement. Do you think you are fine to do the full conversion? Also I think Jianhong had some great feedback too
There was a problem hiding this comment.
Good work @0xh4ty . @Stevengre gave some excellent feedback, and I added a few notes on the trait boundary design.
The traversal extraction is the right idea, and the hard part (generic traversal order, clean separation of items/blocks/edges) is already solid. The main thing to tighten up is where the format-agnostic boundary actually sits: a couple of the trait methods (block, block_edge) still carry D2-specific assumptions, which means the next renderer would inherit those assumptions rather than being free to do its own thing.
The comments above walk through the specifics. Once those methods pass structured data instead of raw MIR types and format-specific conventions, this will be a clean foundation for DOT, Mermaid, and whatever else comes next.
I also want to see more comments, as I want this project to be usable via cargo doc --open, one more sign of a mature Cargo library.
Finally, my vote is to rip out the old, and use this as the way forward. Though, you will have to make sure the operational parts remain consistent so we can generate graphs :)
src/mk_graph/traverse.rs
Outdated
| ) { | ||
| let fn_id = short_name(name); | ||
| let label = name_lines(name); | ||
| let is_local = true; |
There was a problem hiding this comment.
this true assignment is unconditional; is it needed?
There was a problem hiding this comment.
This will be removed.
src/mk_graph/output/d2.rs
Outdated
| self.buf.push_str(&format!(" bb{}: \"{}\"\n", idx, label)); | ||
| } | ||
|
|
||
| fn block_edge(&mut self, _fn_id: &str, from: usize, to: usize, _label: Option<&str>) { |
There was a problem hiding this comment.
Same issue here: block_edge bakes D2's arrow syntax into the implementation, and the Option<&str> label parameter (which it looks like D2Builder ignores?) is a tell that the signature was shaped around what D2 needs today, with a speculative parameter tacked on for a future renderer.
This is the second method where format-specific concerns leak through the trait boundary, so it's worth stepping back and asking: what's the trait actually buying us?
A well-drawn trait boundary gives you two things: the code above it can change without touching the code below (new traversal logic doesn't require touching every renderer), and the code below can change without touching the code above (new output format, same traversal). But that only works if the boundary is at the right level of abstraction. When trait methods receive raw MIR types or carry format-specific assumptions, every new renderer has to understand the same internals, and the trait becomes ceremony rather than insulation.
The fix is the same as for block: have the traversal own the "what" (which blocks connect, what the edge represents) and pass structured, pre-rendered data. Let each renderer own the "how" (syntax, escaping, layout). That way the trait boundary actually earns its keep.
There was a problem hiding this comment.
Thanks, that makes sense. The label parameter was speculative and is not used by the current renderers, so I will remove it to avoid leaking format assumptions into the trait. I will keep traversal responsible for edge semantics and pass structured, pre-rendered data to builders, while the renderers handle syntax and layout.
|
Hey @0xh4ty , I had the opportunity to think this through some more and, well, here you go :) First: the The question I want to think through is: what happens when we go to add DOT, Mermaid, Markdown, or other formats on top of this? Right now, each builder holds a The thing is, the current So here's a concrete idea: instead of the driver pushing raw stable_mir types to the builder via a sequence of granular calls ( /// A single basic block with pre-rendered content and structural edges.
struct RenderedBlock<'a> {
idx: usize,
stmts: Vec<String>, // pre-rendered via GraphContext
terminator: String, // pre-rendered
raw_terminator: &'a Terminator, // escape hatch for structural inspection
cfg_edges: Vec<(usize, Option<String>)>, // (target_block, optional label)
}
/// A fully analyzed function, ready for format-specific rendering.
struct RenderedFunction<'a> {
id: String, // stable ID (short_name + body hash)
display_name: String, // name_lines() output for labels
is_local: bool,
locals: Vec<(usize, String)>, // (index, type_with_layout string)
blocks: Vec<RenderedBlock<'a>>,
call_edges: Vec<CallEdge>, // resolved callee IDs and pre-rendered args
}
struct CallEdge {
block_idx: usize,
callee_id: String,
callee_name: String,
rendered_args: String,
}The traversal driver does all the trait GraphBuilder {
type Output;
fn begin_graph(&mut self, name: &str);
fn alloc_legend(&mut self, lines: &[String]);
fn type_legend(&mut self, lines: &[String]);
fn external_function(&mut self, id: &str, name: &str);
fn render_function(&mut self, func: &RenderedFunction);
fn static_item(&mut self, id: &str, name: &str);
fn asm_item(&mut self, id: &str, content: &str);
fn finish(self) -> Self::Output;
}What does this actually buy us? A few things: No lifetime on builders. Rendering logic written once. The driver calls Still flexible where it matters. The Fewer trait methods. The granular push-based sequence ( New hooks emerge as data, not methods. Instead of adding One more thing worth noting: function IDs. The current branch uses /// Generate a stable, unique ID from a symbol name and body.
/// Handles the case where multiple monomorphizations of the same
/// generic produce the same short_name by incorporating a body hash.
fn make_fn_id(name: &str, body: &Body) -> String { ... }In terms of concrete next steps, I'd suggest:
Of course, feel free to push back, if you have a different roadmap in mind. |
|
Thanks for the detailed explanation. That separation between graph structure and MIR string rendering makes sense. I will refactor the driver to produce |
Add detailed Rustdoc comments for GraphBuilder, RenderedFunction, RenderedBlock, CallEdge, and traversal entry points. The documentation explains the separation between MIR traversal and format-specific rendering, and clarifies the responsibilities of each structure. This improves discoverability via `cargo doc` and makes the graph rendering architecture easier to understand for future contributors.
Resolve compilation issues introduced after rebasing onto upstream master. Remove an unused import, update the SmirJson impl to match the new upstream definition without lifetime parameters, and implement Default for D2Builder to satisfy Clippy's new_without_default lint. Run cargo fmt to normalize formatting.
dbb514a to
9552849
Compare
Remove the unused is_local field from RenderedFunction since the local/external distinction is already represented structurally in the GraphBuilder API. Add raw_stmts to RenderedBlock as an escape hatch for renderers that need access to the underlying MIR statements in addition to the pre-rendered strings.
|
I pushed an update addressing the review feedback. Main changes: • Introduced The D2 output remains the same except for the expected function ID change caused by the body hash used for collision avoidance. |
cds-amal
left a comment
There was a problem hiding this comment.
Thanks @0xh4ty, this has come a long way! The RenderedFunction / RenderedBlock / CallEdge structs are exactly what I had in mind, and the D2 builder is clean and easy to follow. The hard part (extracting traversal from rendering, getting the data shapes right) is done.
A few things to get us across the finish line. I left inline comments on cfg_edges, and hash_body collision; the rest I'll cover here.
raw_stmts and raw_terminator: on second thought, let's not! I want to own this one, because my earlier comment sent you in the wrong direction. I proposed raw_terminator as an escape hatch "for builders that need structural information; say, coloring blocks differently based on terminator kind." Having now seen how the implementation shakes out, I think we're better off without it.
Here's the tell: raw_stmts and raw_terminator are the only reason RenderedBlock and RenderedFunction need a lifetime parameter. Remove them, and the structs become fully owned, which is cleaner for everyone. D2Builder already ignores both fields. And the use case I imagined (coloring based on terminator kind) doesn't need the full MIR type; a simple TerminatorTag enum (just the discriminant, no payload) would do the job if we ever actually need it.
The deeper issue: these fields quietly re-introduce the coupling we're trying to eliminate. The whole point of the RenderedFunction design is that builders see pre-rendered data and don't need to understand MIR internals. An escape hatch that hands them raw &[Statement] and &Terminator undermines that; it's the same boundary leak as the old block(...) method, just made optional instead of required. Let's take them out; if a future builder genuinely needs something the rendered data doesn't provide, that's a signal to enrich the rendered data (like the cfg_edges labels in my inline comment), not to punch through the abstraction.
(My apologies for the whiplash; sometimes we have to see the implementation to realize a design idea doesn't carry its weight.)
external_function is declared but never called: render_graph never calls it. The DOT renderer needs this: it creates red-colored nodes for functions that appear as call targets but aren't in the items list. The traversal has everything it needs to compute this. Either wire it up, or remove it from the trait until it's needed. Dead code in a trait is worse than dead code in a function, because every new impl has to carry it.
I would suggest porting DOT to the new traversal; this is the real proof that the abstraction works if DOT ports cleanly, we know the trait boundary is in the right place. If it doesn't, we'll learn something useful about what's missing. Either way, we come out ahead.
| /// Used to avoid fn_id collisions between monomorphizations. | ||
| pub fn hash_body(body: &Body) -> u64 { |
There was a problem hiding this comment.
It seems strange to hash a function and not use its mangled name, which is guaranteed to be unique. The hash seems to be structurally based and I wonder if multiple generic functions could collide. @dkcumming , what do you think?
|
|
||
| let cfg_edges = terminator_targets(&block.terminator) | ||
| .into_iter() | ||
| .map(|t| (t, None)) |
There was a problem hiding this comment.
This is where information gets lost. The traversal has the full TerminatorKind right here; it knows whether an edge is a SwitchInt branch (with a discriminant value), a cleanup unwind, or a normal successor. But terminator_targets flattens all of that into a plain Vec<usize>, and then this code wraps each target in (t, None), discarding the labels entirely.
The Option<String> in cfg_edges exists precisely for this data. Look at what the DOT renderer needs: SwitchInt edges carry discriminant values like "0", "1", cleanup edges get "Cleanup", the otherwise branch gets "other". That information is available right now, in block.terminator.kind, at this exact point in the traversal. One scope exit later, it's gone; a builder that needs it would have to reach into raw_terminator to re-derive what the traversal already knew.
That's the pattern we're trying to break: the traversal owns the "what" (which blocks connect and what each edge means), and the builder owns the "how" (syntax, escaping, visual style). When the traversal discards edge semantics, it forces builders to do the traversal's job, and the trait boundary stops earning its keep.
I would explore matching on the terminator kind here instead of delegating to terminator_targets.
There was a problem hiding this comment.
Got it. I’ll add matching logic on terminator.kind in the traversal and populate cfg_edges with the appropriate labels
|
Thanks for the clarification, that makes sense. I’ll remove |
Summary
This PR introduces a format-agnostic MIR graph traversal based on a new
GraphBuilderabstraction. Traversal semantics are centralized in a single implementation, while individual renderers are responsible only for presentation.As an initial consumer, the D2 renderer has been ported to this new traversal behind a parallel code path. The legacy D2 implementation is retained for now to allow safe comparison and validation.
What changed
GraphBuildertrait describing semantic graph eventsrender_graph) that owns MIR graph structure and traversal orderGraphBuilderGraphContextcargo fmtandcargo clippyWhy this change
Previously, each renderer duplicated traversal logic, which made the code harder to reason about and maintain. Centralizing traversal ensures:
Validation and testing
The new traversal can be exercised by enabling the experimental D2 path:
To compare outputs against the legacy renderer:
No differences were observed across the existing integration test suite.
Status and follow-ups
Notes for reviewers
This PR is a structural refactor only. Traversal semantics and output behavior are unchanged, and the parallel wiring is intended to make review and validation straightforward.