Skip to content

Commit e3ace6e

Browse files
committed
fx_viewer: fix layout issue with fast-sugiyaga layout
1. use chain detection and iterative spine refinement to avoid overlap and restore compactness and alignment 2. chain detection leverage node depth (rank) to detect longest possible chains 3. chains are attracted to each other by sharing start and end nodes, in spine refinement, mean attracition center will be shifted based on end nodes.
1 parent 187ab55 commit e3ace6e

1 file changed

Lines changed: 82 additions & 47 deletions

File tree

devtools/fx_viewer/exporter.py

Lines changed: 82 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,9 @@ def __init__(self, graph_module: torch.fx.GraphModule):
5555
_NODE_Y_PADDING = 20
5656
_LAYOUT_XSPACE = 50
5757
_LAYOUT_YSPACE = 30
58-
_DUMMY_SIZE = 30 # dummy nodes (from fast-sugiyama) occupy no real width/height
59-
_CHAIN_MIN_LENGTH = 2
60-
_SPINE_COHESION_ALPHA = 0.7
61-
_SPINE_COHESION_ITER = 3
62-
_SPINE_CONTINUITY_EPSILON = 0.3
63-
_EDGE_CLIP_MARGIN = 2.0
58+
_DUMMY_SIZE_X = 100 # dummy nodes (from fast-sugiyama) occupy no real width/height
59+
_DUMMY_SIZE_Y = 30 # dummy nodes (from fast-sugiyama) occupy no real width/height
60+
_SPINE_COHESION_ITER = 20
6461

6562
def _default_base_label(self, node: GraphNode) -> str:
6663
target = str(node.info.get("target") or node.info.get("op") or "")
@@ -326,7 +323,7 @@ def _compute_layout_with_ext_lines(
326323
from fast_sugiyama.layout import Layouts
327324
pack_spacing = max_w + cls._LAYOUT_XSPACE
328325
widest_component = max((w for _pos, w, _h, _e in adjusted), default=0.0)
329-
pack_width = int(max(widest_component + pack_spacing, 2000.0))
326+
pack_width = int(max(widest_component*3 + pack_spacing, 2000.0))
330327
layouts = Layouts(adjusted).rect_pack_layouts(
331328
max_width=pack_width,
332329
spacing=pack_spacing,
@@ -362,11 +359,11 @@ def _compact_components(cls, layouts, nodes, expected_gap):
362359

363360
def _w(nid):
364361
n = nodes.get(nid)
365-
return n.width if n is not None else cls._DUMMY_SIZE
362+
return n.width if n is not None else cls._DUMMY_SIZE_X
366363

367364
def _h(nid):
368365
n = nodes.get(nid)
369-
return n.height if n is not None else cls._DUMMY_SIZE
366+
return n.height if n is not None else cls._DUMMY_SIZE_Y
370367

371368
xspace = cls._LAYOUT_XSPACE
372369
yspace = cls._LAYOUT_YSPACE
@@ -390,16 +387,31 @@ def _sweep_min_gap(nids):
390387
x[cur] = x[prev] + min_gap
391388

392389
# Phase 1: chain detection (real + dummy members)
393-
chains = cls._detect_chains(el or [])
390+
chains = cls._detect_chains(el or [], nodes)
394391

395392
# Phase 2: iterative spine cohesion + pure-A overlap fix
396-
for _ in range(cls._SPINE_COHESION_ITER):
393+
for i in range(cls._SPINE_COHESION_ITER + 2):
394+
395+
# delta for each node
396+
# We record the relative weight (chain length) and their base delta
397+
node_delta: dict = defaultdict(list)
397398
for ch in chains:
398399
if not ch:
399400
continue
400-
target = sum(x[v] for v in ch) / len(ch)
401+
mean_x = sum(x[v] for v in ch) / len(ch)
402+
# emphasize end point
403+
if i < cls._SPINE_COHESION_ITER:
404+
# we use common start and end node of chain to attract chain close together
405+
mean_x = (mean_x + x[ch[0]] + x[ch[-1]]) / 3.0
401406
for v in ch:
402-
x[v] += cls._SPINE_COHESION_ALPHA * (target - x[v])
407+
# weight: len(ch)
408+
# delta: mean_x - x[v]
409+
node_delta[v].append((len(ch), (mean_x - x[v])))
410+
# move the node x
411+
for n, deltas in node_delta.items():
412+
total_weight = sum(w for w, _ in deltas)
413+
x[n] += sum(w / total_weight * d for w, d in deltas)
414+
# adjust node overlapping
403415
for nids in by_y.values():
404416
_sweep_min_gap(nids)
405417

@@ -434,51 +446,74 @@ def _sweep_min_gap(nids):
434446
return new_layouts
435447

436448
@classmethod
437-
def _detect_chains(cls, edge_list):
449+
def _detect_chains(cls, edge_list, nodes):
450+
# Here we break the graph into chains (connected node list)
451+
# The longer the chain the better (aligned visual vertical axis)
452+
# We achieve long chain by calculating best_prev and best_succ with rank
453+
# We must let the chain start and end node to be shared
454+
# The shared end points (common nodes) will be used to pull chains near in later iterative loop
438455
from collections import defaultdict
439456

440457
if not edge_list:
441458
return []
442459

443-
in_deg: dict = defaultdict(int)
444-
out_deg: dict = defaultdict(int)
445-
succ: dict = {}
460+
succ: dict = defaultdict(set)
461+
prev: dict = defaultdict(set)
446462
for u, v in edge_list:
447-
out_deg[u] += 1
448-
in_deg[v] += 1
449-
# Record unique successor only when out_deg == 1 so we avoid
450-
# ambiguity; overwritten on branching but we'll reject branching
451-
# nodes from chain interiors via the in==1 and out==1 test.
452-
succ[u] = v
463+
succ[u].add(v)
464+
prev[v].add(u)
465+
466+
all_nodes = set(succ) | set(prev)
467+
468+
# max depth a node's output can reach
469+
node_out_rank: dict = defaultdict(int)
470+
# the succer node that have maximal node_out_rank
471+
best_succ: dict = {}
472+
graph_output_nodes = [n for n in all_nodes if len(succ[n]) == 0]
473+
stack = graph_output_nodes
474+
while stack:
475+
n = stack.pop()
476+
for pn in prev[n]:
477+
score = 2 if pn in nodes else 1
478+
if node_out_rank[pn] < node_out_rank[n] + score:
479+
node_out_rank[pn] = node_out_rank[n] + score
480+
stack.append(pn)
481+
best_succ[pn] = n
482+
483+
# max depth a node's input can reach
484+
node_in_rank: dict = defaultdict(int)
485+
# the prev node that have maximal node_in_rank
486+
best_prev: dict = {}
487+
graph_input_nodes = [n for n in all_nodes if len(prev[n]) == 0]
488+
stack = graph_input_nodes
489+
while stack:
490+
n = stack.pop()
491+
for nn in succ[n]:
492+
score = 2 if nn in nodes else 1
493+
if node_in_rank[nn] < node_in_rank[n] + score:
494+
node_in_rank[nn] = node_in_rank[n] + score
495+
stack.append(nn)
496+
best_prev[nn] = n
453497

454-
all_nodes = set(in_deg) | set(out_deg)
455-
456-
def is_interior(n):
457-
return out_deg.get(n, 0) == 1
458498

459499
visited: set = set()
460500
chains: list = []
461-
for start in all_nodes:
462-
if is_interior(start):
501+
for start in sorted(all_nodes, key=lambda n:node_out_rank[n], reverse=True):
502+
if start in visited:
463503
continue
464-
# Walk from each successor of `start` forward while interior
465-
# Collect successors from the edge list since `succ` only stores
466-
# one (fine for branch starts, but may miss other branches)
467-
start_succs = [v for (u, v) in edge_list if u == start]
468-
for w in start_succs:
469-
if w in visited:
470-
continue
471-
walk = [start, w]
472-
cur = w
473-
while is_interior(cur) and succ.get(cur) is not None:
474-
nxt = succ[cur]
475-
if nxt in walk:
476-
break
477-
walk.append(nxt)
478-
visited.add(cur)
479-
cur = nxt
480-
if len(walk) >= cls._CHAIN_MIN_LENGTH:
481-
chains.append(walk)
504+
cur = start
505+
walk = [start]
506+
if start in best_prev:
507+
walk.insert(0, best_prev[start])
508+
while cur not in visited:
509+
visited.add(cur)
510+
if cur not in best_succ:
511+
break
512+
nxt = best_succ[cur]
513+
walk.append(nxt)
514+
cur = nxt
515+
if len(walk) >= 2: # always true
516+
chains.append(walk)
482517
return chains
483518

484519
@staticmethod

0 commit comments

Comments
 (0)