Improve handling of cases with cycles
To avoid paying the cost of setup and of a full grounding again, move cycle detection into a separate program and check first if the solution has cycles. If it has, ground only the integrity constraint preventing cycles and solve again.
This commit is contained in:
		
				
					committed by
					
						
						Todd Gamblin
					
				
			
			
				
	
			
			
			
						parent
						
							cf8f44ae5a
						
					
				
				
					commit
					1de5117ef1
				
			@@ -8,6 +8,7 @@
 | 
				
			|||||||
import enum
 | 
					import enum
 | 
				
			||||||
import itertools
 | 
					import itertools
 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
 | 
					import pathlib
 | 
				
			||||||
import pprint
 | 
					import pprint
 | 
				
			||||||
import re
 | 
					import re
 | 
				
			||||||
import types
 | 
					import types
 | 
				
			||||||
@@ -790,9 +791,6 @@ def visit(node):
 | 
				
			|||||||
        self.control.load(os.path.join(parent_dir, "display.lp"))
 | 
					        self.control.load(os.path.join(parent_dir, "display.lp"))
 | 
				
			||||||
        if not setup.concretize_everything:
 | 
					        if not setup.concretize_everything:
 | 
				
			||||||
            self.control.load(os.path.join(parent_dir, "when_possible.lp"))
 | 
					            self.control.load(os.path.join(parent_dir, "when_possible.lp"))
 | 
				
			||||||
 | 
					 | 
				
			||||||
        if setup.use_cycle_detection:
 | 
					 | 
				
			||||||
            self.control.load(os.path.join(parent_dir, "cycle_detection.lp"))
 | 
					 | 
				
			||||||
        timer.stop("load")
 | 
					        timer.stop("load")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Grounding is the first step in the solve -- it turns our facts
 | 
					        # Grounding is the first step in the solve -- it turns our facts
 | 
				
			||||||
@@ -820,6 +818,14 @@ def on_model(model):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        timer.start("solve")
 | 
					        timer.start("solve")
 | 
				
			||||||
        solve_result = self.control.solve(**solve_kwargs)
 | 
					        solve_result = self.control.solve(**solve_kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if solve_result.satisfiable and self._model_has_cycles(models):
 | 
				
			||||||
 | 
					            warnings.warn(f"cycles detected, falling back to slower algorithm [specs={specs}]")
 | 
				
			||||||
 | 
					            self.control.load(os.path.join(parent_dir, "cycle_detection.lp"))
 | 
				
			||||||
 | 
					            self.control.ground([("no_cycle", [])])
 | 
				
			||||||
 | 
					            models.clear()
 | 
				
			||||||
 | 
					            solve_result = self.control.solve(**solve_kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        timer.stop("solve")
 | 
					        timer.stop("solve")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # once done, construct the solve result
 | 
					        # once done, construct the solve result
 | 
				
			||||||
@@ -872,6 +878,26 @@ def on_model(model):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        return result, timer, self.control.statistics
 | 
					        return result, timer, self.control.statistics
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _model_has_cycles(self, models):
 | 
				
			||||||
 | 
					        """Returns true if the best model has cycles in it"""
 | 
				
			||||||
 | 
					        cycle_detection = clingo.Control()
 | 
				
			||||||
 | 
					        parent_dir = pathlib.Path(__file__).parent
 | 
				
			||||||
 | 
					        lp_file = parent_dir / "cycle_detection.lp"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        min_cost, best_model = min(models)
 | 
				
			||||||
 | 
					        with cycle_detection.backend() as backend:
 | 
				
			||||||
 | 
					            for atom in best_model:
 | 
				
			||||||
 | 
					                if atom.name == "attr" and str(atom.arguments[0]) == '"depends_on"':
 | 
				
			||||||
 | 
					                    symbol = fn.depends_on(atom.arguments[1], atom.arguments[2])
 | 
				
			||||||
 | 
					                    atom_id = backend.add_atom(symbol.symbol())
 | 
				
			||||||
 | 
					                    backend.add_rule([atom_id], [], choice=False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            cycle_detection.load(str(lp_file))
 | 
				
			||||||
 | 
					            cycle_detection.ground([("base", []), ("no_cycle", [])])
 | 
				
			||||||
 | 
					            cycle_result = cycle_detection.solve()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return cycle_result.unsatisfiable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class SpackSolverSetup:
 | 
					class SpackSolverSetup:
 | 
				
			||||||
    """Class to set up and run a Spack concretization solve."""
 | 
					    """Class to set up and run a Spack concretization solve."""
 | 
				
			||||||
@@ -915,7 +941,6 @@ def __init__(self, tests=False):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        # If False allows for input specs that are not solved
 | 
					        # If False allows for input specs that are not solved
 | 
				
			||||||
        self.concretize_everything = True
 | 
					        self.concretize_everything = True
 | 
				
			||||||
        self.use_cycle_detection = False
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Set during the call to setup
 | 
					        # Set during the call to setup
 | 
				
			||||||
        self.pkgs = None
 | 
					        self.pkgs = None
 | 
				
			||||||
@@ -2741,7 +2766,7 @@ def sort_fn(function_tuple):
 | 
				
			|||||||
        else:
 | 
					        else:
 | 
				
			||||||
            return (-1, 0)
 | 
					            return (-1, 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _build_specs(self, function_tuples):
 | 
					    def build_specs(self, function_tuples):
 | 
				
			||||||
        # Functions don't seem to be in particular order in output.  Sort
 | 
					        # Functions don't seem to be in particular order in output.  Sort
 | 
				
			||||||
        # them here so that directives that build objects (like node and
 | 
					        # them here so that directives that build objects (like node and
 | 
				
			||||||
        # node_compiler) are called in the right order.
 | 
					        # node_compiler) are called in the right order.
 | 
				
			||||||
@@ -2831,14 +2856,6 @@ def _build_specs(self, function_tuples):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        return self._specs
 | 
					        return self._specs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def build_specs(self, function_tuples):
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            return self._build_specs(function_tuples)
 | 
					 | 
				
			||||||
        except RecursionError as e:
 | 
					 | 
				
			||||||
            raise CycleDetectedError(
 | 
					 | 
				
			||||||
                "detected cycles using a fast solve, falling back to slower algorithm"
 | 
					 | 
				
			||||||
            ) from e
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
def _develop_specs_from_env(spec, env):
 | 
					def _develop_specs_from_env(spec, env):
 | 
				
			||||||
    dev_info = env.dev_specs.get(spec.name, {}) if env else {}
 | 
					    dev_info = env.dev_specs.get(spec.name, {}) if env else {}
 | 
				
			||||||
@@ -2940,11 +2957,6 @@ def solve(self, specs, out=None, timers=False, stats=False, tests=False, setup_o
 | 
				
			|||||||
        reusable_specs.extend(self._reusable_specs(specs))
 | 
					        reusable_specs.extend(self._reusable_specs(specs))
 | 
				
			||||||
        setup = SpackSolverSetup(tests=tests)
 | 
					        setup = SpackSolverSetup(tests=tests)
 | 
				
			||||||
        output = OutputConfiguration(timers=timers, stats=stats, out=out, setup_only=setup_only)
 | 
					        output = OutputConfiguration(timers=timers, stats=stats, out=out, setup_only=setup_only)
 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            result, _, _ = self.driver.solve(setup, specs, reuse=reusable_specs, output=output)
 | 
					 | 
				
			||||||
        except CycleDetectedError as e:
 | 
					 | 
				
			||||||
            warnings.warn(e)
 | 
					 | 
				
			||||||
            setup.use_cycle_detection = True
 | 
					 | 
				
			||||||
        result, _, _ = self.driver.solve(setup, specs, reuse=reusable_specs, output=output)
 | 
					        result, _, _ = self.driver.solve(setup, specs, reuse=reusable_specs, output=output)
 | 
				
			||||||
        return result
 | 
					        return result
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -3025,7 +3037,3 @@ def __init__(self, provided, conflicts):
 | 
				
			|||||||
        # Add attribute expected of the superclass interface
 | 
					        # Add attribute expected of the superclass interface
 | 
				
			||||||
        self.required = None
 | 
					        self.required = None
 | 
				
			||||||
        self.constraint_type = None
 | 
					        self.constraint_type = None
 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class CycleDetectedError(spack.error.SpackError):
 | 
					 | 
				
			||||||
    pass
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,8 +6,10 @@
 | 
				
			|||||||
% Avoid cycles in the DAG
 | 
					% Avoid cycles in the DAG
 | 
				
			||||||
% some combinations of conditional dependencies can result in cycles;
 | 
					% some combinations of conditional dependencies can result in cycles;
 | 
				
			||||||
% this ensures that we solve around them
 | 
					% this ensures that we solve around them
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#program no_cycle.
 | 
				
			||||||
path(Parent, Child) :- depends_on(Parent, Child).
 | 
					path(Parent, Child) :- depends_on(Parent, Child).
 | 
				
			||||||
path(Parent, Descendant) :- path(Parent, A), depends_on(A, Descendant).
 | 
					path(Parent, Descendant) :- path(Parent, A), depends_on(A, Descendant).
 | 
				
			||||||
error(100, "Cyclic dependency detected between '{0}' and '{1}' (consider changing variants to avoid the cycle)", A, B)
 | 
					:- path(A, A).
 | 
				
			||||||
  :- path(A, B),
 | 
					
 | 
				
			||||||
     path(B, A).
 | 
					#defined depends_on/2.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2209,7 +2209,7 @@ def test_two_gmake(self, strategy):
 | 
				
			|||||||
        o gmake@4.1
 | 
					        o gmake@4.1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        spack.config.config.set("concretizer:duplicates:strategy", strategy)
 | 
					        spack.config.CONFIG.set("concretizer:duplicates:strategy", strategy)
 | 
				
			||||||
        s = Spec("hdf5").concretized()
 | 
					        s = Spec("hdf5").concretized()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Check that hdf5 depends on gmake@=4.1
 | 
					        # Check that hdf5 depends on gmake@=4.1
 | 
				
			||||||
@@ -2245,7 +2245,7 @@ def test_two_setuptools(self, strategy):
 | 
				
			|||||||
        o gmake@4.1
 | 
					        o gmake@4.1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        spack.config.config.set("concretizer:duplicates:strategy", strategy)
 | 
					        spack.config.CONFIG.set("concretizer:duplicates:strategy", strategy)
 | 
				
			||||||
        s = Spec("py-shapely").concretized()
 | 
					        s = Spec("py-shapely").concretized()
 | 
				
			||||||
        # Requirements on py-shapely
 | 
					        # Requirements on py-shapely
 | 
				
			||||||
        setuptools = s["py-shapely"].dependencies(name="py-setuptools", deptype="build")
 | 
					        setuptools = s["py-shapely"].dependencies(name="py-setuptools", deptype="build")
 | 
				
			||||||
@@ -2260,3 +2260,19 @@ def test_two_setuptools(self, strategy):
 | 
				
			|||||||
        # Requirements on python
 | 
					        # Requirements on python
 | 
				
			||||||
        gmake = s["python"].dependencies(name="gmake", deptype="build")
 | 
					        gmake = s["python"].dependencies(name="gmake", deptype="build")
 | 
				
			||||||
        assert len(gmake) == 1 and gmake[0].satisfies("@=3.0")
 | 
					        assert len(gmake) == 1 and gmake[0].satisfies("@=3.0")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @pytest.mark.skipif(
 | 
				
			||||||
 | 
					        os.environ.get("SPACK_TEST_SOLVER") == "original",
 | 
				
			||||||
 | 
					        reason="Not supported by the original concretizer",
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    def test_solution_without_cycles(self):
 | 
				
			||||||
 | 
					        """Tests that when we concretize a spec with cycles, a fallback kicks in to recompute
 | 
				
			||||||
 | 
					        a solution without cycles.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        s = Spec("cycle-a").concretized()
 | 
				
			||||||
 | 
					        assert s["cycle-a"].satisfies("+cycle")
 | 
				
			||||||
 | 
					        assert s["cycle-b"].satisfies("~cycle")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        s = Spec("cycle-b").concretized()
 | 
				
			||||||
 | 
					        assert s["cycle-a"].satisfies("~cycle")
 | 
				
			||||||
 | 
					        assert s["cycle-b"].satisfies("+cycle")
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										17
									
								
								var/spack/repos/duplicates.test/packages/cycle-a/package.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								var/spack/repos/duplicates.test/packages/cycle-a/package.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					# Copyright 2013-2023 Lawrence Livermore National Security, LLC and other
 | 
				
			||||||
 | 
					# Spack Project Developers. See the top-level COPYRIGHT file for details.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# SPDX-License-Identifier: (Apache-2.0 OR MIT)
 | 
				
			||||||
 | 
					from spack.package import *
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CycleA(Package):
 | 
				
			||||||
 | 
					    """Package that would lead to cycles if default variant values are used"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    homepage = "http://www.example.com"
 | 
				
			||||||
 | 
					    url = "http://www.example.com/tdep-1.0.tar.gz"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    version("2.0", md5="0123456789abcdef0123456789abcdef")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    variant("cycle", default=True, description="activate cycles")
 | 
				
			||||||
 | 
					    depends_on("cycle-b", when="+cycle")
 | 
				
			||||||
							
								
								
									
										17
									
								
								var/spack/repos/duplicates.test/packages/cycle-b/package.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								var/spack/repos/duplicates.test/packages/cycle-b/package.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					# Copyright 2013-2023 Lawrence Livermore National Security, LLC and other
 | 
				
			||||||
 | 
					# Spack Project Developers. See the top-level COPYRIGHT file for details.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# SPDX-License-Identifier: (Apache-2.0 OR MIT)
 | 
				
			||||||
 | 
					from spack.package import *
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CycleB(Package):
 | 
				
			||||||
 | 
					    """Package that would lead to cycles if default variant values are used"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    homepage = "http://www.example.com"
 | 
				
			||||||
 | 
					    url = "http://www.example.com/tdep-1.0.tar.gz"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    version("2.0", md5="0123456789abcdef0123456789abcdef")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    variant("cycle", default=True, description="activate cycles")
 | 
				
			||||||
 | 
					    depends_on("cycle-a", when="+cycle")
 | 
				
			||||||
		Reference in New Issue
	
	Block a user