Skip to content

Commit 7e19866

Browse files
authored
Merge pull request #457 from nerdvegas/better_solver_debugging_output
Better solver debugging output; fixed 2 solve bugs
2 parents f70013d + 1541403 commit 7e19866

File tree

14 files changed

+4302
-285
lines changed

14 files changed

+4302
-285
lines changed

src/rez/SOLVER.md

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
# Description Of Solver Algorithm
2+
3+
## Overview
4+
5+
* A **phase** is a current state of the solve. It contains a list of **scopes**.
6+
* A **scope** is a package request. If the request isn't a conflict, then a scope
7+
also contains the actual list of variants that match the request.
8+
9+
The solve loop performs 5 different types of operations:
10+
11+
* **EXTRACT**. This happens when a common dependency is found in all the variants
12+
in a scope. For example if every version of pkg 'foo' depends on some version
13+
of python, the 'extracted' dependency might be "python-2.6|2.7".
14+
15+
* **MERGE-EXTRACTIONS**. When one or more scopes are successfully *extracted*,
16+
this results in a list of package requests. This list is then merged into a new
17+
list, which may be unchanged, or simpler, or may cause a conflict. If a conflict
18+
occurs then the phase is in conflict, and fails.
19+
20+
* **INTERSECT**: This happens when an extracted dependency overlaps with an existing
21+
scope. For example "python-2" might be a current scope. Pkg foo's common dependency
22+
python-2.6|2.7 would be 'intersected' with this scope. This might result in a
23+
conflict, which would cause the whole phase to fail (and possibly the whole solve).
24+
Or, as in this case, it narrows an existing scope to 'python-2.6|2.7'.
25+
26+
* **ADD**: This happens when an extraction is a new pkg request. A new scope is
27+
created and added to the current list of scopes.
28+
29+
* **REDUCE**: This is when a scope iterates over all of its variants and removes those
30+
that conflict with another scope. If this removes all the variants in the scope,
31+
the phase has failed - this is called a "total reduction". This type of failure
32+
is not common - usually it's a conflicting INTERSECT that causes a failure.
33+
34+
* **SPLIT**: Once a phase has been extracted/intersected/added/reduced as much as
35+
possible (this is called 'exhausted'), we are left with either a solution (each
36+
scope contains only a single variant), or an unsolved phase. This is when the
37+
algorithm needs to recurse (although it doesn't actually recurse, it uses a stack
38+
instead). A SPLIT occurs at this point. The first scope with more than one
39+
variant is found. This scope is split in two (let us say ScopeA and ScopeB),
40+
where ScopeA has at least one common dependency (worst case scenario, ScopeA
41+
contains a single variant). This is done because it guarantees a later extraction,
42+
which hopefully gets us closer to a solution. Now, two phases are created (let us
43+
say PhaseA and PhaseB) - identical to the current phase, except that PhaseA has
44+
ScopeA instead of the original, and PhaseB has ScopeB instead of the original.
45+
Now, we attempt to solve PhaseA, and if that fails, we attempt to solve PhaseB.
46+
47+
Following the process above, we maintain a 'phase stack'. We run a loop, and in
48+
each loop, we attempt to solve the phase at the top of the stack. If the phase
49+
becomes exhaused, then it is split, and replaced with 2 phases (so the stack
50+
grows by 1). If the phase is solved, then we have the solution, and the other
51+
phases are discarded. If the phase fails to solve, then it is removed from the
52+
stack - if the stack is then empty, then there is no solution.
53+
54+
## Pseudocode
55+
56+
The pseudocode for a solve looks like this (and yes, you will have to read the
57+
solver code for full appreciation of what's going on here):
58+
59+
def solve(requests):
60+
phase = create_initial_phase(requests)
61+
phase_stack = stack()
62+
phase_stack.push(phase)
63+
64+
while not solved():
65+
phase = phase_stack.pop()
66+
67+
if phase.failed:
68+
phase = phase_stack.pop() # discard previous failed phase
69+
70+
if phase.exhausted:
71+
phase, next_phase = phase.split()
72+
phase_stack.push(next_phase)
73+
74+
new_phase = solve_phase(phase)
75+
76+
if new_phase.failed:
77+
phase_stack.push(new_phase) # we keep last fail on the stack
78+
elif new_phase.solved:
79+
# some housekeeping here, like checking for cycles
80+
final_phase = finalise_phase(new_phase)
81+
phase_stack.push(final_phase)
82+
else:
83+
phase_stack.push(new_phase) # phase is exhausted
84+
85+
def solve_phase(phase):
86+
while True:
87+
changed_scopes = []
88+
added_scopes = []
89+
widened_scopes = []
90+
91+
while True:
92+
extractions = []
93+
94+
foreach phase.scope as scope:
95+
extractions |= collect_extractions(scope)
96+
97+
if not extractions:
98+
break
99+
100+
merge(extractions)
101+
if in_conflict(extractions):
102+
set_fail()
103+
return
104+
105+
foreach phase.scope as scope:
106+
intersect(scope, extractions)
107+
108+
if failed(scope):
109+
set_fail()
110+
return
111+
112+
if was_intersected(scope):
113+
changed_scopes.add(scope)
114+
115+
if was_widened(scope):
116+
widened_scopes.add(scope)
117+
118+
# get those extractions involving new packages
119+
new_extractions = get_new_extractions(extractions)
120+
121+
# add them as new scopes
122+
foreach request in new_extractions:
123+
scope = new_scope(request)
124+
added_scopes.add(scope)
125+
phase.add(scope)
126+
127+
if no (changed_scopes or added_scopes or widened_scopes):
128+
break
129+
130+
pending_reductions = convert_to_reduction_set(
131+
changed_scopes, added_scopes, widened_scopes)
132+
133+
while pending_reductions:
134+
scope_a, scope_b = pending_reductions.pop()
135+
scope_a.reduce_by(scope_b)
136+
137+
if totally_reduced(scope_a):
138+
set_fail()
139+
return
140+
141+
# scope_a changed so other scopes need to reduce against it again
142+
if was_reduced(scope_a):
143+
foreach phase.scope as scope:
144+
if scope is not scope_a:
145+
pending_reductions.add(scope, scope_a)
146+
147+
There are 2 notable points missing from the pseudocode, related to optimisations:
148+
149+
* Scopes keep a set of package families so that they can quickly skip unnecessary
150+
reductions. For example, all 'foo' pkgs may depend only on the set (python, bah),
151+
so when reduced against 'maya', this becomes basically a no-op.
152+
153+
* Objects in the solver (phases, scopes etc) are immutable. Whenever a change
154+
occurs - such as a scope being narrowed as a result of an intersect - what
155+
actually happens is that a new object is created, often based on a shallow copy
156+
of the previous object. This is basically implementing copy-on-demand - lots of
157+
scopes are shared between phases in the stack, if objects were not immutable
158+
then creating a new phase would involve a deep copy of the entire state of the
159+
solver.
160+
161+
## Interpreting Debugging Output
162+
163+
Solver debugging is enabled using the *rez-env* *-v* flag. Repeat for more
164+
vebosity, to a max of *-vvv*.
165+
166+
### Scope Syntax
167+
168+
Before describing all the sections of output during a solve, we need to explain
169+
the scope syntax. This describes the state of a scope, and you'll see it a lot
170+
in solver output.
171+
172+
* `[foo==1.2.0]` This is a scope containing exactly one variant. In this case it
173+
is a *null* variant (a package that has no variants).
174+
175+
* `[foo-1.2.0[1]]` This is a scope containing exactly one variant. This example
176+
shows the 1-index variant of the package foo-1.2.0
177+
178+
* `[foo-1.2.0[0,1]]` This is a scope containing two variants from one package version.
179+
180+
* `foo[1.2.0..1.3.5(6)]` This is a scope containing 6 variants from 6 different
181+
package versions, where the packages are all >= 1.2.0 and <= 1.3.5.
182+
183+
* `foo[1.2.0..1.3.5(6:8)]` This is a scope containing 8 variants from 6 different
184+
package versions.
185+
186+
In all of the above cases, you may see a trailing `*`, eg `[foo-1.2.0[0,1]]*`.
187+
This indicates that there are still outstanding *extractions* for this scope.
188+
189+
### Output Steps
190+
191+
request: foo-1.2 bah-3 ~foo-1
192+
193+
You will see this once, at the start of the solve. It simply prints the initial
194+
request list.
195+
196+
merged request: foo-1.2 bah-3
197+
198+
You will see this once and immediately after the `request:` output. It shows a
199+
simplified (merged) version of the initial request. Notice here how `~foo-1` is
200+
gone - this is because the intersection of `foo-1.2` and `~foo-1` is simply
201+
`foo-1.2`.
202+
203+
pushed {0,0}: [foo==1.2.0[0,1]]* bah[3.0.5..3.4.0(6)]*
204+
205+
This is pushing the initial *phase* onto the *phase stack*. The `{0,0}` means
206+
that:
207+
208+
* There is 1 phase in the stack (this is the zeroeth phase - phases are pushed
209+
and popped from the bottom of the stack);
210+
* Zero other phases have already been solved (or failed) at this depth so far.
211+
212+
--------------------------------------------------------------------------------
213+
SOLVE #1...
214+
--------------------------------------------------------------------------------
215+
216+
This output indicates that a phase is starting. The number indicates the number
217+
of phases that have been solved so far (1-indexed), regardless of how many have
218+
failed or succeeded.
219+
220+
popped {0,0}: [foo==1.2.0[0,1]]* bah[3.0.5..3.4.0(6)]*
221+
222+
This is always the first thing you see after the `SOLVE #1...` output. The
223+
topmost phase is being retrieved from the phase stack.
224+
225+
EXTRACTING:
226+
extracted python-2 from [foo==1.2.0[0,1]]*
227+
extracted utils-1.2+ from bah[3.0.5..3.4.0(6)]*
228+
229+
This lists extractions that have occurred from current scopes.
230+
231+
MERGE-EXTRACTIONS:
232+
merged extractions are: python-2 utils-1.2+
233+
234+
This shows the result of merging a set of extracted package requests into a
235+
potentially simpler (or conflicting) set of requests.
236+
237+
INTERSECTING:
238+
python[2.7.3..3.3.0(3)] was intersected to [python==2.7.3] by range '2'
239+
240+
This shows scopes that were intersected by previous extractions.
241+
242+
ADDING:
243+
added utils[1.2.0..5.2.0(12:14)]*
244+
245+
This shows scopes that were added for new extractions (ie, extractions that
246+
introduce a new package into the solve).
247+
248+
REDUCING:
249+
removed blah-35.0.2[1] (dep(python-3.6) <--!--> python==2.7.3)
250+
[blah==35.0.2[0,1]] was reduced to [blah==35.0.2[0]]* by python==2.7.3
251+
252+
This shows any reductions and the scopes that have changed as a result.

src/rez/cli/env.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ def setup_parser(parser, completions=False):
9797
parser.add_argument(
9898
"--detached", action="store_true",
9999
help="open a separate terminal")
100+
parser.add_argument(
101+
"--no-passive", action="store_true",
102+
help="only print actions that affect the solve (has an effect only "
103+
"when verbosity is enabled)")
104+
parser.add_argument(
105+
"--stats", action="store_true",
106+
help="print advanced solver stats")
100107
parser.add_argument(
101108
"--pre-command", type=str, help=SUPPRESS)
102109
PKG_action = parser.add_argument(
@@ -192,7 +199,9 @@ def command(opts, parser, extra_arg_groups=None):
192199
verbosity=opts.verbose,
193200
max_fails=opts.max_fails,
194201
time_limit=opts.time_limit,
195-
caching=(not opts.no_cache))
202+
caching=(not opts.no_cache),
203+
suppress_passive=opts.no_passive,
204+
print_stats=opts.stats)
196205

197206
success = (context.status == ResolverStatus.solved)
198207
if not success:

src/rez/resolved_context.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,8 @@ def __init__(self, package_requests, verbosity=0, timestamp=None,
139139
building=False, caching=None, package_paths=None,
140140
package_filter=None, package_orderers=None, max_fails=-1,
141141
add_implicit_packages=True, time_limit=-1, callback=None,
142-
package_load_callback=None, buf=None):
142+
package_load_callback=None, buf=None, suppress_passive=False,
143+
print_stats=False):
143144
"""Perform a package resolve, and store the result.
144145
145146
Args:
@@ -170,6 +171,10 @@ def __init__(self, package_requests, verbosity=0, timestamp=None,
170171
`Package` object.
171172
buf (file-like object): Where to print verbose output to, defaults
172173
to stdout.
174+
suppress_passive (bool): If True, don't print debugging info that
175+
has had no effect on the solve. This argument only has an
176+
effect if `verbosity` > 2.
177+
print_stats (bool): If true, print advanced solver stats at the end.
173178
"""
174179
self.load_path = None
175180

@@ -259,7 +264,10 @@ def _package_load_callback(package):
259264
callback=callback_,
260265
package_load_callback=_package_load_callback,
261266
verbosity=verbosity,
262-
buf=buf)
267+
buf=buf,
268+
suppress_passive=suppress_passive,
269+
print_stats=print_stats)
270+
263271
resolver.solve()
264272

265273
# convert the results

src/rez/resolver.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ class Resolver(object):
3333
"""
3434
def __init__(self, context, package_requests, package_paths, package_filter=None,
3535
package_orderers=None, timestamp=0, callback=None, building=False,
36-
verbosity=False, buf=None, package_load_callback=None, caching=True):
36+
verbosity=False, buf=None, package_load_callback=None, caching=True,
37+
suppress_passive=False, print_stats=False):
3738
"""Create a Resolver.
3839
3940
Args:
@@ -49,6 +50,7 @@ def __init__(self, context, package_requests, package_paths, package_filter=None
4950
building: True if we're resolving for a build.
5051
caching: If True, cache(s) may be used to speed the resolve. If
5152
False, caches will not be used.
53+
print_stats (bool): If true, print advanced solver stats at the end.
5254
"""
5355
self.context = context
5456
self.package_requests = package_requests
@@ -61,6 +63,8 @@ def __init__(self, context, package_requests, package_paths, package_filter=None
6163
self.verbosity = verbosity
6264
self.caching = caching
6365
self.buf = buf
66+
self.suppress_passive = suppress_passive
67+
self.print_stats = print_stats
6468

6569
# store hash of package orderers. This is used in the memcached key
6670
if package_orderers:
@@ -383,7 +387,9 @@ def _solve(self):
383387
building=self.building,
384388
verbosity=self.verbosity,
385389
prune_unfailed=config.prune_failed_graph,
386-
buf=self.buf)
390+
buf=self.buf,
391+
suppress_passive=self.suppress_passive,
392+
print_stats=self.print_stats)
387393
solver.solve()
388394

389395
return solver

0 commit comments

Comments
 (0)