Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract/Improve Polyhedron class #178

Merged
merged 22 commits into from
Nov 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
354 changes: 354 additions & 0 deletions loki/analyse/util_polyhedron.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,354 @@
# (C) Copyright 2018- ECMWF.
# This software is licensed under the terms of the Apache Licence Version 2.0
# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
# In applying this licence, ECMWF does not waive the privileges and immunities
# granted to it by virtue of its status as an intergovernmental organisation
# nor does it submit to any jurisdiction.

from typing import List
import numpy as np
from loki.ir import Loop
from loki.expression import (
symbols as sym,
FindVariables,
simplify,
is_constant,
accumulate_polynomial_terms,
)
from loki.tools import as_tuple

__all__ = ["Polyhedron"]


class Polyhedron:
"""
Halfspace representation of a (convex) polyhedron.

A polyhedron `P c R^d` is described by a set of inequalities, in matrix form
```
P = { x=[x1,...,xd]^T c R^d | Ax <= b }
```
with n-by-d matrix `A` and d-dimensional right hand side `b`.

In loop transformations, polyhedrons are used to represent iteration spaces of
d-deep loop nests.


Parameters
----------
A : numpy.array
The representation matrix A.
b : numpy.array
The right hand-side vector b.
variables : list, optional
List of variables representing the dimensions in the polyhedron.

Attributes
----------
A : numpy.array
The representation matrix A.
b : numpy.array
The right hand-side vector b.
variables : list, optional, default = None
"""

def __init__(self, A, b, variables=None):
A = np.array(A, dtype=np.dtype(int))
b = np.array(b, dtype=np.dtype(int))
assert A.ndim == 2 and b.ndim == 1
assert A.shape[0] == b.shape[0]
self.A = A
self.b = b

self.variables = None
self.variable_names = None
if variables is not None:
assert len(variables) == A.shape[1]
self.variables = variables
self.variable_names = [v.name.lower() for v in self.variables]

def __str__(self):
str_A = "[" + ", ".join([str(row) for row in self.A]) + "]"
str_b = f"[{', '.join(map(str, self.b))}]"
str_variable_names = (
f"[{', '.join(map(str, self.variable_names))}]"
if self.variable_names
else "[]"
)

return f"Polyhedron(\n\tA={str_A}, \n\tb={str_b}, \n\tvariables={str_variable_names}\n)"

def __repr__(self):
return str(self)

def _has_satisfiable_constant_restrictions(self):
"""
Check whether the constant restrictions of the polyhedron are satisfiable.

This method checks if 0 <= b, assuming that A x = 0.

Returns:
bool: True if all constant restrictions are satisfiable, False otherwise.

"""

return (0 <= self.b).all()

def is_empty(self):
"""
Determine whether a polyhedron is empty.

A polyhedron is considered empty under the following conditions:
1. It contains no inequalities.
2. It spans no space, which is a nontrivial problem. The simplest case is when it has an empty
matrix A and does not satisfy the constant restrictions 0 <= b.

Notes
-----
An empty polyhedron implies that it has no valid solutions or feasible points within its boundaries.
This function is expected to be called only for polyhedrons with an empty matrix.

Returns
-------
bool
True if the polyhedron is empty; False if it is not.
"""
if self.A.size == 0:
return self.b.size == 0 or not self._has_satisfiable_constant_restrictions()

raise RuntimeError(
"""
Checking if a polyhedron with a non-empty matrix spans no space is a nontrivial problem.
This function is expected to be only called upon polyhedrons with an empty matrix!
"""
)

def variable_to_index(self, variable):
if self.variable_names is None:
raise RuntimeError("No variables list associated with polyhedron.")
if isinstance(variable, sym.TypedSymbol):
variable = variable.name.lower()
assert isinstance(variable, str)
return self.variable_names.index(variable)

@staticmethod
def _to_literal(value):
if value < 0:
return sym.Product((-1, sym.IntLiteral(abs(value))))
return sym.IntLiteral(value)

def lower_bounds(self, index_or_variable, ignore_variables=None):
"""
Return all lower bounds imposed on a variable.

The lower bounds for the variable `j` are given by the index set:
```
L = {i | A_ij < 0, i in {0, ..., d-1}}
```
Parameters
----------
index_or_variable : int or str or sym.Array or sym.Scalar
The index, name, or expression symbol for which the lower bounds are produced.
ignore_variables : list or None, optional
A list of variable names, indices, or symbols for which constraints should be ignored
if they depend on one of them.

Returns
-------
list
The bounds for the specified variable.
"""
if isinstance(index_or_variable, int):
j = index_or_variable
else:
j = self.variable_to_index(index_or_variable)

if ignore_variables:
ignore_variables = [
i if isinstance(i, int) else self.variable_to_index(i)
for i in ignore_variables
]

bounds = []
for i in range(self.A.shape[0]):
if self.A[i, j] < 0:
if ignore_variables and any(
self.A[i, k] != 0 for k in ignore_variables
):
# Skip constraint that depends on any of the ignored variables
continue

components = [
self._to_literal(self.A[i, k]) * self.variables[k]
for k in range(self.A.shape[1])
if k != j and self.A[i, k] != 0
]
if not components:
lhs = sym.IntLiteral(0)
elif len(components) == 1:
lhs = components[0]
else:
lhs = sym.Sum(as_tuple(components))
bounds += [
simplify(
sym.Quotient(
self._to_literal(self.b[i]) - lhs,
self._to_literal(self.A[i, j]),
)
)
]
return bounds

def upper_bounds(self, index_or_variable, ignore_variables=None):
"""
Return all upper bounds imposed on a variable.

The upper bounds for the variable `j` are given by the index set:
```
U = {i | A_ij > 0, i in {0, ..., d-1}}
```
Parameters
----------
index_or_variable : int or str or sym.Array or sym.Scalar
The index, name, or expression symbol for which the upper bounds are produced.
ignore_variables : list or None, optional
A list of variable names, indices, or symbols for which constraints should be ignored
if they depend on one of them.

Returns
-------
list
The bounds for the specified variable.
"""
if isinstance(index_or_variable, int):
j = index_or_variable
else:
j = self.variable_to_index(index_or_variable)

if ignore_variables:
ignore_variables = [
i if isinstance(i, int) else self.variable_to_index(i)
for i in ignore_variables
]

bounds = []
for i in range(self.A.shape[0]):
if self.A[i, j] > 0:
if ignore_variables and any(
self.A[i, k] != 0 for k in ignore_variables
):
# Skip constraint that depends on any of the ignored variables
continue

components = [
self._to_literal(self.A[i, k]) * self.variables[k]
for k in range(self.A.shape[1])
if k != j and self.A[i, k] != 0
]
if not components:
lhs = sym.IntLiteral(0)
elif len(components) == 1:
lhs = components[0]
else:
lhs = sym.Sum(as_tuple(components))
bounds += [
simplify(
sym.Quotient(
self._to_literal(self.b[i]) - lhs,
self._to_literal(self.A[i, j]),
)
)
]
return bounds

@staticmethod
def generate_entries_for_lower_bound(bound, variables, index):
"""
Helper routine to generate matrix and right-hand side entries for a given lower bound.

Note that this routine can only handle affine bounds, which means expressions that are
constant or can be reduced to a linear polynomial.

Upper bounds can be derived from this by multiplying the left-hand side and right-hand side
with -1.

Parameters
----------
bound : int or str or sym.Array or sym.Scalar
The expression representing the lower bound.
variables : list of str
The list of variable names.
index : int
The index of the variable constrained by this bound.

Returns
-------
tuple(np.array, np.array)
The pair ``(lhs, rhs)`` of the row in the matrix inequality, where `lhs` is the left-hand side
and `rhs` is the right-hand side.
"""
supported_types = (sym.TypedSymbol, sym.MetaSymbol, sym.Sum, sym.Product)
if not (is_constant(bound) or isinstance(bound, supported_types)):
raise ValueError(f"Cannot derive inequality from bound {str(bound)}")
summands = accumulate_polynomial_terms(bound)
b = -summands.pop(1, 0) # Constant term or 0
A = np.zeros([1, len(variables)], dtype=np.dtype(int))
A[0, index] = -1
for base, coef in summands.items():
if not len(base) == 1:
raise ValueError(f"Non-affine bound {str(bound)}")
A[0, variables.index(base[0].name.lower())] = coef
return A, b

@classmethod
def from_loop_ranges(cls, loop_variables, loop_ranges):
"""
Create polyhedron from a list of loop ranges and associated variables.
"""
assert len(loop_ranges) == len(loop_variables)

# Add any variables that are not loop variables to the vector of variables
variables = list(loop_variables)
variable_names = [v.name.lower() for v in variables]
for v in sorted(
FindVariables().visit(loop_ranges), key=lambda v: v.name.lower()
):
if v.name.lower() not in variable_names:
variables += [v]
variable_names += [v.name.lower()]

n = 2 * len(loop_ranges)
d = len(variables)
A = np.zeros([n, d], dtype=np.dtype(int))
b = np.zeros([n], dtype=np.dtype(int))

for i, (loop_variable, loop_range) in enumerate(
zip(loop_variables, loop_ranges)
):
assert loop_range.step is None or loop_range.step == "1"
j = variables.index(loop_variable.name.lower())

# Create inequality from lower bound
lhs, rhs = cls.generate_entries_for_lower_bound(
loop_range.start, variable_names, j
)
A[2 * i, :] = lhs
b[2 * i] = rhs

# Create inequality from upper bound
lhs, rhs = cls.generate_entries_for_lower_bound(
loop_range.stop, variable_names, j
)
A[2 * i + 1, :] = -lhs
b[2 * i + 1] = -rhs

return cls(A, b, variables)

@classmethod
def from_nested_loops(cls, nested_loops: List[Loop]):
"""
Helper function, for creating a polyhedron from a list of loops.
"""
return cls.from_loop_ranges(
[l.variable for l in nested_loops], [l.bounds for l in nested_loops]
)
Loading
Loading