Skip to content

Commit f79c708

Browse files
committed
perf(graph): cache the visit order of graphs
Graphs are immutable, but visit_postorder and visit_preorder sorted them topologically again every time they were called. The full task graph is visited once per registered verification (11 times in taskgraph alone), then again to serialize it. The target task graph is visited several times during optimization. Now the order is computed once per graph and direction, and cached like links_and_reverse_links_dict already is. Also, during optimization: - index paths are gathered by iterating over the tasks directly, as the order doesn't matter; - remove_tasks uses the cached reverse links instead of building them again. On a synthetic graph of 20,210 tasks and 40,200 edges, verifying the full task graph takes 0.34s instead of 0.73s, and optimizing it takes 1.57s instead of 2.19s.
1 parent c6f48bb commit f79c708

3 files changed

Lines changed: 17 additions & 4 deletions

File tree

‎src/taskgraph/graph.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,21 +107,30 @@ def _visit(self, reverse):
107107
f"Dependency loop detected involving the following nodes: {loopy_nodes}"
108108
)
109109

110+
@functools.cache
111+
def _visit_order(self, reverse):
112+
"""
113+
Return the order in which `_visit` yields nodes as a tuple. The graph
114+
is immutable, so this is cached to avoid sorting it again every time
115+
it is visited.
116+
"""
117+
return tuple(self._visit(reverse))
118+
110119
def visit_postorder(self):
111120
"""
112121
Generate a sequence of nodes in postorder, such that every node is
113122
visited *after* any nodes it links to.
114123
115124
Raises an exception if the graph contains a cycle.
116125
"""
117-
return self._visit(False)
126+
return iter(self._visit_order(False))
118127

119128
def visit_preorder(self):
120129
"""
121130
Like visit_postorder, but in reverse: evrey node is visited *before*
122131
any nodes it links to.
123132
"""
124-
return self._visit(True)
133+
return iter(self._visit_order(True))
125134

126135
@functools.cache
127136
def links_and_reverse_links_dict(self):

‎src/taskgraph/optimize/base.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def optimize_task_graph(
7878

7979
# Gather each relevant task's index
8080
indexes = set()
81-
for label in target_task_graph.graph.visit_postorder():
81+
for label in target_task_graph.tasks:
8282
if label in do_not_optimize:
8383
continue
8484
_, strategy, arg = optimizations(label)
@@ -157,7 +157,7 @@ def remove_tasks(
157157
opt_counts = defaultdict(int)
158158
opt_reasons = {}
159159
removed = set()
160-
dependents_of = target_task_graph.graph.reverse_links_dict()
160+
_, dependents_of = target_task_graph.graph.links_and_reverse_links_dict()
161161
tasks = target_task_graph.tasks
162162
prune_candidates = set()
163163

‎test/test_graph_perf.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ def test_transitive_closure(geometry):
124124
@pytest.mark.parametrize("geometry", ["linear", "fan", "btree", "diamond"])
125125
def test_visit_postorder(geometry):
126126
_, graph, _ = GEOMETRIES[geometry]
127+
# Clear the functools.cache to measure actual computation each time
128+
graph._visit_order.cache_clear()
127129
order = list(graph.visit_postorder())
128130
assert len(order) == N
129131

@@ -132,6 +134,8 @@ def test_visit_postorder(geometry):
132134
@pytest.mark.parametrize("geometry", ["linear", "fan", "btree", "diamond"])
133135
def test_visit_preorder(geometry):
134136
_, graph, _ = GEOMETRIES[geometry]
137+
# Clear the functools.cache to measure actual computation each time
138+
graph._visit_order.cache_clear()
135139
order = list(graph.visit_preorder())
136140
assert len(order) == N
137141

0 commit comments

Comments
 (0)