diff --git a/reframe/frontend/dependency.py b/reframe/frontend/dependency.py index 4f11bf4ae6..f888d6500d 100644 --- a/reframe/frontend/dependency.py +++ b/reframe/frontend/dependency.py @@ -6,6 +6,7 @@ import itertools import reframe as rfm +import reframe.utility as util from reframe.core.exceptions import DependencyError @@ -50,7 +51,6 @@ def resolve_dep(target, from_map, *args): graph = {} for c in cases: - graph[c] = c.deps cname = c.check.name pname = c.partition.fullname ename = c.environ.name @@ -70,6 +70,8 @@ def resolve_dep(target, from_map, *args): c.deps.append(resolve_dep(c, cases_revmap, tname, pname, te)) + graph[c] = util.OrderedSet(c.deps) + return graph @@ -78,6 +80,19 @@ def print_deps(graph): print(c, '->', deps) +def _reduce_deps(graph): + """Reduce test case graph to a test-only graph.""" + ret = {} + for case, deps in graph.items(): + test_deps = util.OrderedSet(d.check.name for d in deps) + try: + ret[case.check.name] |= test_deps + except KeyError: + ret[case.check.name] = test_deps + + return ret + + def validate_deps(graph): """Validate dependency graph.""" @@ -87,16 +102,7 @@ def validate_deps(graph): # (t0, e1) -> (t1, e1) # (t1, e0) -> (t0, e0) # - # This reduction step will result in a graph description with duplicate - # entries in the adjacency list; this is not a problem, cos they will be - # filtered out during the DFS traversal below. - test_graph = {} - for case, deps in graph.items(): - test_deps = [d.check.name for d in deps] - try: - test_graph[case.check.name] += test_deps - except KeyError: - test_graph[case.check.name] = test_deps + test_graph = _reduce_deps(graph) # Check for cyclic dependencies in the test name graph visited = set() @@ -112,7 +118,7 @@ def validate_deps(graph): while path and path[-1] != parent: path.pop() - adjacent = reversed(test_graph[node]) + adjacent = test_graph[node] path.append(node) for n in adjacent: if n in path: @@ -126,3 +132,85 @@ def validate_deps(graph): visited.add(node) sources -= visited + + +def _reverse_deps(graph): + ret = {} + for n, deps in graph.items(): + ret.setdefault(n, util.OrderedSet({})) + for d in deps: + try: + ret[d] |= {n} + except KeyError: + ret[d] = util.OrderedSet({n}) + + return ret + + +def toposort(graph): + # NOTES on implementation: + # + # 1. This function assumes a directed acyclic graph. + # 2. The purpose of this function is to topologically sort the test cases, + # not only the tests. However, since we do not allow cycles between + # tests in any case (even if this could be classified a + # pseudo-dependency), we first do a topological sort of the tests and we + # subsequently sort the test cases by partition and by programming + # environment. + # 3. To achieve this 3-step sorting with a single sort operations, we rank + # the test cases by associating them with an integer key based on the + # result of the topological sort of the tests and by choosing an + # arbitrary ordering of the partitions and the programming environment. + + test_deps = _reduce_deps(graph) + rev_deps = _reverse_deps(test_deps) + + # We do a BFS traversal from each root + visited = {} + roots = set(t for t, deps in test_deps.items() if not deps) + for r in roots: + unvisited = util.OrderedSet([r]) + visited[r] = util.OrderedSet() + while unvisited: + # Next node is one whose all dependencies are already visited + # FIXME: This makes sorting's complexity O(V^2) + node = None + for n in unvisited: + if test_deps[n] <= visited[r]: + node = n + break + + # If node is None, graph has a cycle and this is a bug; this + # function assumes acyclic graphs only + assert node is not None + + unvisited.remove(node) + adjacent = rev_deps[node] + unvisited |= util.OrderedSet( + n for n in adjacent if n not in visited + ) + visited[r].add(node) + + # Combine all individual sequences into a single one + ordered_tests = util.OrderedSet() + for tests in visited.values(): + ordered_tests |= tests + + # Get all partitions and programming environments from test cases + partitions = util.OrderedSet() + environs = util.OrderedSet() + for c in graph.keys(): + partitions.add(c.partition.fullname) + environs.add(c.environ.name) + + # Rank test cases; we first need to calculate the base for the rank number + base = max(len(partitions), len(environs)) + 1 + ranks = {} + for i, test in enumerate(ordered_tests): + for j, part in enumerate(partitions): + for k, env in enumerate(environs): + ranks[test, part, env] = i*base**2 + j*base + k + + return sorted(graph.keys(), + key=lambda x: ranks[x.check.name, + x.partition.fullname, x.environ.name]) diff --git a/reframe/utility/__init__.py b/reframe/utility/__init__.py index 7c85f4a811..caaba49297 100644 --- a/reframe/utility/__init__.py +++ b/reframe/utility/__init__.py @@ -1,5 +1,6 @@ import abc import collections +import functools import importlib import importlib.util import itertools @@ -243,6 +244,195 @@ def __missing__(self, key): raise KeyError(str(key)) +@functools.total_ordering +class OrderedSet(collections.abc.MutableSet): + """An ordered set.""" + + def __init__(self, *args): + # We need to allow construction without arguments + if not args: + iterable = [] + elif len(args) == 1: + iterable = args[0] + else: + # We use the exact same error message as for the built-in set + raise TypeError('%s expected at most 1 arguments, got %s' % + type(self).__name__, len(args)) + + if not isinstance(iterable, collections.abc.Iterable): + raise TypeError("'%s' object is not iterable" % + type(iterable).__name__) + + # We implement an ordered set through the keys of an OrderedDict; + # its values are all set to None + self.__data = collections.OrderedDict( + itertools.zip_longest(iterable, [], fillvalue=None) + ) + + def __repr__(self): + vals = self.__data.keys() + if not vals: + return type(self).__name__ + '()' + else: + return '{' + ', '.join(str(v) for v in vals) + '}' + + # Container i/face + def __contains__(self, item): + return item in self.__data + + def __iter__(self): + return iter(self.__data) + + def __len__(self): + return len(self.__data) + + # Set i/face + # + # Note on the complexity of the operators + # + # In every case below we first construct a set from the internal ordered + # dictionary's keys and then apply the operator. This step's complexity is + # O(len(self.__data.keys())). Since the complexity of the standard set + # operators are at the order of magnitute of the lenghts of the operands + # (ranging from O(min(len(a), len(b))) to O(len(a) + len(b))), this step + # does not change the complexity class; it just changes the constant + # factor. + # + def __eq__(self, other): + if not isinstance(other, collections.abc.Set): + return NotImplemented + + return set(self.__data.keys()) == other + + def __gt__(self, other): + if not isinstance(other, collections.abc.Set): + return NotImplemented + + return set(self.__data.keys()) > other + + def __and__(self, other): + if not isinstance(other, collections.abc.Set): + return NotImplemented + + return set(self.__data.keys()) & other + + def __or__(self, other): + if not isinstance(other, collections.abc.Set): + return NotImplemented + + return set(self.__data.keys()) | other + + def __sub__(self, other): + if not isinstance(other, collections.abc.Set): + return NotImplemented + + return set(self.__data.keys()) - other + + def __xor__(self, other): + if not isinstance(other, collections.abc.Set): + return NotImplemented + + return set(self.__data.keys()) ^ other + + def isdisjoint(self, other): + if not isinstance(other, collections.abc.Set): + return NotImplemented + + return set(self.__data.keys()).isdisjoint(other) + + def issubset(self, other): + return self <= other + + def issuperset(self, other): + return self >= other + + def symmetric_difference(self, other): + return self ^ other + + def union(self, *others): + ret = type(self)(self) + for s in others: + ret |= s + + return ret + + def intersection(self, *others): + ret = type(self)(self) + for s in others: + ret &= s + + return ret + + def difference(self, *others): + ret = type(self)(self) + for s in others: + ret -= s + + return ret + + # MutableSet i/face + + def add(self, elem): + self.__data[elem] = None + + def remove(self, elem): + del self.__data[elem] + + def discard(self, elem): + try: + self.remove(elem) + except KeyError: + pass + + def pop(self): + return self.__data.popitem()[0] + + def clear(self): + self.__data.clear() + + def __ior__(self, other): + if not isinstance(other, collections.abc.Set): + return NotImplemented + + for e in other: + self.add(e) + + return self + + def __iand__(self, other): + if not isinstance(other, collections.abc.Set): + return NotImplemented + + discard_list = [e for e in self if e not in other] + for e in discard_list: + self.discard(e) + + return self + + def __isub__(self, other): + if not isinstance(other, collections.abc.Set): + return NotImplemented + + for e in other: + self.discard(e) + + return self + + def __ixor__(self, other): + if not isinstance(other, collections.abc.Set): + return NotImplemented + + discard_list = [e for e in self if e in other] + for e in discard_list: + self.discard(e) + + return self + + # Other functions + def __reversed__(self): + return reversed(self.__data.keys()) + + class SequenceView(collections.abc.Sequence): """A read-only view of a sequence.""" diff --git a/unittests/test_policies.py b/unittests/test_policies.py index 903a606cdd..0da4acde96 100644 --- a/unittests/test_policies.py +++ b/unittests/test_policies.py @@ -1,4 +1,5 @@ import collections +import itertools import os import pytest import tempfile @@ -9,6 +10,7 @@ import reframe.frontend.dependency as dependency import reframe.frontend.executors as executors import reframe.frontend.executors.policies as policies +import reframe.utility as util import reframe.utility.os_ext as os_ext from reframe.core.exceptions import DependencyError, JobNotStartedError from reframe.frontend.loader import RegressionCheckLoader @@ -666,7 +668,6 @@ def test_cyclic_deps(self): t1.depends_on('t4') t2.depends_on('t1') t3.depends_on('t1') - t3.depends_on('t2') t4.depends_on('t2') t4.depends_on('t3') t6.depends_on('t5') @@ -705,3 +706,67 @@ def test_cyclic_deps_by_env(self): @rt.switch_runtime(fixtures.TEST_SITE_CONFIG, 'sys0') def test_validate_deps_empty(self): dependency.validate_deps({}) + + @rt.switch_runtime(fixtures.TEST_SITE_CONFIG, 'sys0') + def test_toposort(self): + # + # t0 +-->t5<--+ + # ^ | | + # | | | + # +-->t1<--+ t6 t7 + # | | ^ + # t2<------t3 | + # ^ ^ | + # | | t8 + # +---t4---+ + # + t0 = self.create_test('t0') + t1 = self.create_test('t1') + t2 = self.create_test('t2') + t3 = self.create_test('t3') + t4 = self.create_test('t4') + t5 = self.create_test('t5') + t6 = self.create_test('t6') + t7 = self.create_test('t7') + t8 = self.create_test('t8') + t1.depends_on('t0') + t2.depends_on('t1') + t3.depends_on('t1') + t3.depends_on('t2') + t4.depends_on('t2') + t4.depends_on('t3') + t6.depends_on('t5') + t7.depends_on('t5') + t8.depends_on('t7') + deps = dependency.build_deps( + executors.generate_testcases([t0, t1, t2, t3, t4, + t5, t6, t7, t8]) + ) + cases = dependency.toposort(deps) + cases_order = [] + tests = util.OrderedSet() + visited_tests = set() + for c in cases: + check, part, env = c + cases_order.append((check.name, part.fullname, env.name)) + tests.add(check.name) + visited_tests.add(check.name) + + # Assert that all dependencies of c have been visited before + for d in deps[c]: + assert d.check.name in visited_tests + + # Check the order of systems and prog. environments + # We are checking against all possible orderings + valid_orderings = [] + for partitions in itertools.permutations(['sys0:p0', 'sys0:p1']): + for environs in itertools.permutations(['e0', 'e1']): + ordering = [] + for t in tests: + for p in partitions: + for e in environs: + ordering.append((t, p, e)) + + valid_orderings.append(ordering) + + assert cases_order in valid_orderings diff --git a/unittests/test_typecheck.py b/unittests/test_typecheck.py index d024c6afb1..b7f1a98509 100644 --- a/unittests/test_typecheck.py +++ b/unittests/test_typecheck.py @@ -117,7 +117,7 @@ def test_type_names(self): types.Dict[str, types.List[int]].__name__) self.assertEqual('Tuple[int,Set[float],str]', types.Tuple[int, types.Set[float], str].__name__) - self.assertEqual("List[Str[r'\d+']]", + self.assertEqual(r"List[Str[r'\d+']]", types.List[types.Str[r'\d+']].__name__) def test_custom_types(self): diff --git a/unittests/test_utility.py b/unittests/test_utility.py index 9c0b9944a8..e797cb8ecf 100644 --- a/unittests/test_utility.py +++ b/unittests/test_utility.py @@ -1,4 +1,6 @@ import os +import pytest +import random import shutil import sys import tempfile @@ -853,3 +855,124 @@ def test_mapping(self): with self.assertRaises(AttributeError): d.setdefault('c', 3) + + +class TestOrderedSet(unittest.TestCase): + def setUp(self): + # Initialize all tests with the same seed + random.seed(1) + + def test_construction(self): + l = list(range(10)) + random.shuffle(l) + + s = util.OrderedSet(l + l) + assert len(s) == 10 + for i in range(10): + assert i in s + + assert list(s) == l + + def test_construction_empty(self): + s = util.OrderedSet() + assert s == set() + assert set() == s + + def test_str(self): + l = list(range(10)) + random.shuffle(l) + + s = util.OrderedSet(l) + assert str(s) == str(l).replace('[', '{').replace(']', '}') + + s = util.OrderedSet() + assert str(s) == type(s).__name__ + '()' + + def test_construction_error(self): + with pytest.raises(TypeError): + s = util.OrderedSet(2) + + with pytest.raises(TypeError): + s = util.OrderedSet(1, 2, 3) + + def test_operators(self): + s0 = util.OrderedSet(range(10)) + s1 = util.OrderedSet(range(20)) + s2 = util.OrderedSet(range(10, 20)) + + assert s0 == set(range(10)) + assert set(range(10)) == s0 + assert s0 != s1 + assert s1 != s0 + + assert s0 < s1 + assert s0 <= s1 + assert s0 <= s0 + assert s1 > s0 + assert s1 >= s0 + assert s1 >= s1 + + assert s0.issubset(s1) + assert s1.issuperset(s0) + + assert (s0 & s1) == s0 + assert (s0 & s2) == set() + assert (s0 | s2) == s1 + + assert (s1 - s0) == s2 + assert (s2 - s0) == s2 + + assert (s0 ^ s1) == s2 + + assert s0.isdisjoint(s2) + assert not s0.isdisjoint(s1) + assert s0.symmetric_difference(s1) == s2 + + def test_union(self): + l0 = list(range(10)) + l1 = list(range(10, 20)) + l2 = list(range(20, 30)) + random.shuffle(l0) + random.shuffle(l1) + random.shuffle(l2) + + s0 = util.OrderedSet(l0) + s1 = util.OrderedSet(l1) + s2 = util.OrderedSet(l2) + + assert list(s0.union(s1, s2)) == l0 + l1 + l2 + + def test_intersection(self): + l0 = list(range(10, 40)) + l1 = list(range(20, 40)) + l2 = list(range(20, 30)) + random.shuffle(l0) + random.shuffle(l1) + random.shuffle(l2) + + s0 = util.OrderedSet(l0) + s1 = util.OrderedSet(l1) + s2 = util.OrderedSet(l2) + + assert s0.intersection(s1, s2) == s2 + + def test_difference(self): + l0 = list(range(10, 40)) + l1 = list(range(20, 40)) + l2 = list(range(20, 30)) + random.shuffle(l0) + random.shuffle(l1) + random.shuffle(l2) + + s0 = util.OrderedSet(l0) + s1 = util.OrderedSet(l1) + s2 = util.OrderedSet(l2) + + assert s0.difference(s1, s2) == set(range(10, 20)) + + def test_reversed(self): + l = list(range(10)) + random.shuffle(l) + + s = util.OrderedSet(l) + assert list(reversed(s)) == list(reversed(l))