Consolidate DAG traversal in traverse.py, support DFS/BFS#33406
Consolidate DAG traversal in traverse.py, support DFS/BFS#33406tgamblin merged 24 commits intospack:developfrom
Conversation
c6ede84 to
64d7332
Compare
|
@spackbot run pipeline |
|
I've started that pipeline for you! |
|
@haampie and I talked about this and the one remaining nit is that BFS will produce a somewhat unintuitive display of dependencies -- i.e. you aren't guaranteed that the line above any node you look at is that node's parent. @haampie is going to look into whether we can tweak the vis code slightly to handle this, since there are lot of advantages to BFS, but I think we still want clear connections bt/w nodes in the tree output. I think it'll be a way better tree if you're guaranteed to see each node's undiscovered children at each level and you're guaranteed that nodes shown at a given level of indentation are children of the first outdented thing above them. |
|
I like the new version better -- it presents things higher up when it can and the parent/child relationships are real ones. LGTM! You can see:
It's more compact and easier to read IMO |
|
The other kind of cool thing about this is that you could add a depth argument/default to |
|
Closed / reopen to trigger a new merge commit without changing history in the branch. Fix in #33429 should get us past the previous CI error. |
659b258 to
100c480
Compare
|
@tgamblin one key (pun intended) difference between current dfs logic and bfs is that the former defaults to unique on |
|
@spackbot run pipeline |
|
I've started that pipeline for you! |
|
Hm, can readthedocs be re-triggered? Otherwise code coverage looks good now. |
|
Not in this PR, but when feeding environment root specs to the BFS and outputting a tree, you can get these kinds of concretizer outputs (no duplication of python, and as shallow output as possible): I think @adamjstewart would appreciate that ;p |
|
I like this a lot. I always check the DAG to see what version of Python I'm building with but it's always buried somewhere in a deep tree even though it's a direct dependency. |
Logically, I don't see a problem with using There are a few other considerations to think about, most of which don't have anything to do with
|
tgamblin
left a comment
There was a problem hiding this comment.
Some questions/change requests. See also the comment above for some broader context.
lib/spack/spack/traverse.py
Outdated
| yield item | ||
|
|
||
|
|
||
| def traverse_breadth_first_tree(specs, cover="nodes", deptype="all"): |
There was a problem hiding this comment.
This and other functions that talk about trees should be renamed -- they don't just traverse trees; they traverse graphs, right?
There was a problem hiding this comment.
Hm, there's two cases, both for which None is the root of a tree.
cover=nodes: the edge-induced subgraph is always a tree (since every node is discovered through 1 parent)cover=edges: the edges do not form a tree as there can be multiple parents per node, but you have to combine it with theparentsmap to interpret it as a tree (the parents map keeps track of how the node was discovered first).
I just called it traverse_breadth_first_tree since it produces edges of a tree in depth-first order
There was a problem hiding this comment.
Probably this should also receive order=pre/post as a kwarg
|
@tgamblin I've consolidated the depth/breadth first traversal in a single generator now: spack.traverse.traverse_edges([spec_a, spec_b], order="pre/post/breadth")with the usual kwargs. The I'm quite happy with the implementation, especially now that the cover/direction logic is extracted to visitors and reused in both traversal orders. Two comments:
Edit: solved 2 by creating a small helper |
tgamblin
left a comment
There was a problem hiding this comment.
Mostly minor change requests and one that requires more thought.
RE:
- So, I would strongly suggest to avoid
idcause the bug is so subtle, instead we should default tokey= lambda s: s.dag_hash().
Yeah I agree with this, but with the aforementioned caveat that dag_hash() is definitely the right default for concrete specs, but it's going to hurt for abstract specs. Can you see how changing the default affects performance of these two tests?
spack python -c 'import spack.store; [spec.traverse() for spec in spack.store.db.query()]'
spack python -c 'import spack.repo; [p for p in spack.repo.path.all_package_classes()]'That will test traversal speed and comparison speed (which uses hashes) for concrete and abstract specs.
Can you also see how these compare with develop before #33481? Probably better to do that comparison first, actually (as it'll tell you if the sort in traverse() is expensive)
- Edit: solved 2 by creating a small helper
traverse_treethat returns an appropriate generator.
This is nice.
|
Regarding the performance, here are some details regarding concrete specs (I also checked they were doing the same thing). This is on a database with 485 specs. import spack.store
specs = spack.store.db.query()
%timeit [[x.name for x in s.traverse(...)] for s in specs]develop
|
|
Regarding abstract specs, yeah, there's a huge penalty, I guess it's an extra dag traversal per Here's some code to generate a DAG with just abstract specsimport random
import string
import spack.spec
def random_str():
return ''.join(random.choice(string.ascii_lowercase) for _ in range(random.randint(3, 8)))
def random_variant():
if random.random() < 0.4:
return random.choice(('+', '~')) + random_str()
return random_str() + '=' + random_str()
def random_spec_str():
components = [random_str()]
components.extend(random_variant() for _ in range(random.randint(0, 4)))
return ' '.join(components)
def random_deptype():
return random.sample(("link", "build", "run", "test"), random.randint(1, 4))
def generate_random_abstract_specs(num_nodes=500, edge_odds=0.1):
specs = [spack.spec.Spec(random_spec_str()) for _ in range(num_nodes)]
total_edges = 0
for i in range(num_nodes):
num_edges = int(random.random() * edge_odds * (num_nodes - i - 1))
total_edges += num_edges
for j in random.sample(range(i+1, num_nodes), num_edges):
specs[i].add_dependency_edge(specs[j], deptype=random_deptype())
return specs, total_edgesAnd then for example, generating a dag with a 100 nodes and 192 edges: In [1]: specs, edges = spack.spec.generate_random_abstract_specs(100, 0.1)
In [2]: %timeit [[x.name for x in s.traverse(order='breadth', cover='edges', key=lambda s: s.dag_hash())] for s in specs]
95 ms ± 809 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [3]: %timeit [[x.name for x in s.traverse(order='breadth', cover='edges')] for s in specs]
2.37 ms ± 7.34 µs per loop (mean ± std. dev. of 7 runs, 100 loops each) |
|
Finally, actually dumping a tree for each concrete spec in the database: In [12]: %timeit [s.tree(depth_first=True) for s in specs]
1.05 s ± 5.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [13]: %timeit [s.tree(depth_first=False) for s in specs]
1 s ± 9.27 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)I also noticed there's an amazing O(n^2) string concat issue here, lots of |
|
Final comment before next round of reviews: I think it's good to leave the PR as is, with the original |
This seems maybe worth fixing in some later PR (though it's likely not bottlenecking anything at the moment) |
This PR introduces breadth-first traversal, and moves depth-first traversal logic out of Spec's member functions, into `traverse.py`. It introduces a high-level API with three main methods: ```python spack.traverse.traverse_edges(specs, kwargs...) spack.traverse.traverse_nodes(specs, kwags...) spack.traverse.traverse_tree(specs, kwargs...) ``` with the usual `root`, `order`, `cover`, `direction`, `deptype`, `depth`, `key`, `visited` kwargs for the first two. What's new is that `order="breadth"` is added for breadth-first traversal. The lower level API is not exported, but is certainly useful for advanced use cases. The lower level API includes visitor classes for direction reversal and edge pruning, which can be used to create more advanced traversal methods, especially useful when the `deptype` is not constant but depends on the node or depth. --- There's a couple nice use-cases for breadth-first traversal: - Sometimes roots have to be handled differently (e.g. follow build edges of roots but not of deps). BFS ensures that root nodes are always discovered at depth 0, instead of at any depth > 1 as a dep of another root. - When printing a tree, it would be nice to reduce indent levels so it fits in the terminal, and ensure that e.g. `zlib` is not printed at indent level 10 as a dependency of a build dep of a build dep -- rather if it's a direct dep of my package, I wanna see it at depth 1. This basically requires one breadth-first traversal to construct a tree, which can then be printed with depth-first traversal. - In environments in general, it's sometimes inconvenient to have a double loop: first over the roots then over each root's deps, and maintain your own `visited` set outside. With BFS, you can simply init the queue with the environment root specs and it Just Works. [Example here](https://github.com/spack/spack/blob/3ec73046995d9504d6e135f564f1370cfe31ba34/lib/spack/spack/environment/environment.py#L1815-L1816)
This PR introduces breadth-first traversal, and moves depth-first traversal logic out of Spec's member functions, into `traverse.py`. It introduces a high-level API with three main methods: ```python spack.traverse.traverse_edges(specs, kwargs...) spack.traverse.traverse_nodes(specs, kwags...) spack.traverse.traverse_tree(specs, kwargs...) ``` with the usual `root`, `order`, `cover`, `direction`, `deptype`, `depth`, `key`, `visited` kwargs for the first two. What's new is that `order="breadth"` is added for breadth-first traversal. The lower level API is not exported, but is certainly useful for advanced use cases. The lower level API includes visitor classes for direction reversal and edge pruning, which can be used to create more advanced traversal methods, especially useful when the `deptype` is not constant but depends on the node or depth. --- There's a couple nice use-cases for breadth-first traversal: - Sometimes roots have to be handled differently (e.g. follow build edges of roots but not of deps). BFS ensures that root nodes are always discovered at depth 0, instead of at any depth > 1 as a dep of another root. - When printing a tree, it would be nice to reduce indent levels so it fits in the terminal, and ensure that e.g. `zlib` is not printed at indent level 10 as a dependency of a build dep of a build dep -- rather if it's a direct dep of my package, I wanna see it at depth 1. This basically requires one breadth-first traversal to construct a tree, which can then be printed with depth-first traversal. - In environments in general, it's sometimes inconvenient to have a double loop: first over the roots then over each root's deps, and maintain your own `visited` set outside. With BFS, you can simply init the queue with the environment root specs and it Just Works. [Example here](https://github.com/spack/spack/blob/3ec73046995d9504d6e135f564f1370cfe31ba34/lib/spack/spack/environment/environment.py#L1815-L1816)





This PR introduces breadth-first traversal, and moves depth-first traversal
logic out of Spec's member functions, into
traverse.py.It introduces a high-level API with three main methods:
with the usual
root,order,cover,direction,deptype,depth,key,visitedkwargs for the first two.What's new is that
order="breadth"is added for breadth-first traversal.The lower level API is not exported, but is certainly useful for advanced use
cases. The lower level API includes visitor classes for direction reversal and
edge pruning, which can be used to create more advanced traversal methods,
especially useful when the
deptypeis not constant but depends on the nodeor depth.
There's a couple nice use-cases for breadth-first traversal:
roots but not of deps). BFS ensures that root nodes are always discovered at
depth 0, instead of at any depth > 1 as a dep of another root.
terminal, and ensure that e.g.
zlibis not printed at indent level 10 as adependency of a build dep of a build dep -- rather if it's a direct dep of my
package, I wanna see it at depth 1. This basically requires one breadth-first
traversal to construct a tree, which can then be printed with depth-first traversal.
loop: first over the roots then over each root's deps, and maintain your own
visitedset outside. With BFS, you can simply init the queue with theenvironment root specs and it Just Works. Example here