Factors believed to be impacting IC performance
The following is a list of factors which are believed to be impacting the
performance of the IC solver (as of ECLiPSe 5.5) along with how they're
expected to be addressed in the rewrite (intended for ECLiPSe 5.6).
Note that these issues (and the rewrite) focus on linear constraints: these
are by far the most common kind of constraint, and thus the most important
to make fast (and any improvements will have the most impact).
It would be undesirable to implement and evaluate all of the alternatives
discussed here, since it would be a waste of manpower to do this for alternatives
we have reason to believe are likely to be worse, or would be costly to
implement for little expected benefit. Such alternatives are listed
for completeness, and to explain why we think they're not worth trying.
Also, it is not expected to be possible to implement and properly evaluate
even all the "worthwhile" alternatives before the 5.6 release.
-
Processor rounding modes
-
The use of processor rounding modes for interval arithmetic (rather than
conservatively rounding all results) was implemented for the 5.5 release.
It would have been quite difficult to maintain both the new and old approaches
at the same time, and no formal evaluation of the impact of this change
has been done, but it seems fairly clear that using processor rounding
modes is faster and more accurate, and it allowed the simplification of
a fair bit of code. The trade-off is that a small amount of code
needed to be written for each combination of operating system and processor.
-
(Is there still some work to be done on simplifying other code? E.g. linearize
or something?)
-
set_var_type
-
The primitive IC constraints that appear in code after compile-time transformation
typically assume that all variables are already IC variables, and that
any variables required to be integral have already been constrained to
be integral. This results in lots of calls to set_var_type/2 in transformed
code, most of which would be expected to be redundant, since most variables
appearing in constraints will already have appeared in other constraints,
and hence don't need to be "set up". It ought to be more efficient
to handle the cases where this is not the case in the C code which sets
up a constraint (at least for constraints which have C code to set them
up... :) - without using exceptions if possible (I can't see why they'd
be needed anyway).
-
sync_ic_bounds & attrs vs. vars
-
Having constraints keep references to its variables' attributes rather
than the variables themselves seemed like a good idea during the initial
IC implementation, since it saved dereferencing the variables each time
the constraint was propagated. Unfortunately it means that if two variables
are unified, the attribute which would normally be thrown away must instead
be kept, and kept in sync with the retained attribute, in case one or more
constraints still have references to that attribute. Unifications do occur
in "normal" programs, and the synchronisation is expensive. Plus, in a
number of cases we want the actual variable instead or as well, which effectively
negates the benefit. Hence we think constraints should keep references
to the variables themselves rather than to the attributes.
-
In order to evaluate the overhead of dereferencing the variables, it is
proposed to construct an experiment which simulates the access cost of
a pass over a constraint stored using variables (for various chain lengths)
against the same constraint stored using attributes. By evaluating
this overhead, it should be possible to decide which arrangement should
be better without having to have both schemes implemented in the full-blown
constraint solver.
-
Coefficient representation
-
Currently, constraint coefficients are stored in their "native" form (after
floats are converted to bounded reals). Since these are then converted
to a pair of floating point bounds and an integrality flag every time they
are used, it may be worth storing them in the converted form instead (this
may take more space, but should be faster).
-
It should be fairly straightforward to have both schemes implemented, with
a compile-time switch between them, for comparison purposes.
-
Domain representation
-
Integer domains with elements excluded are currently represented using
a bitmap. This is impractical for very large domains, and inefficient
(in space and time) for very sparse domains. One alternative is to
use a list of intervals representation á là the FD solver.
It's probably worth trying to devise a standard "holey domain" interface
which would be supported by all alternative implementations, to facilitate
interchanging between them (either for experimentation purposes or permanent
substitution of one by another).
-
Constraint reduction
-
For the integer solvers developed for my PhD, it was shown that simplifying
constraints as variables became ground was worthwhile. For the current
implementation of IC, this is probably not true, at least when simplifying
to the small "specialised" constraints, since that involves killing the
old constraint and setting up the new one (which then gets propagated even
though the old constraint has already done the propagation). In the new
implementation, I plan to have (linear) constraints continue to look the
same at the ECLiPSe level, even when simplification and specialisation
is going on at the C level. Thus there will be no killing of constraints
(unless they're entailed) and no setting up of new constraints just because
the constraint has been simplified. With the reduction in overheads of
the simplification, it should be the case that the simplification is worthwhile;
that said, it should be straightforward to allow the simplification to
be turned off at compile-time, for comparison purposes.
-
Waking frequency and suspension mechanisms
-
With constraints implemented as demons, equations always wake themselves,
even if there is no need (though there may be need in some cases). It is
possible to avoid this if IC manages its own waking lists, though "normal"
ECLiPSe waking lists would also be required for use by the user. Note that
such self-managed lists, if done in the right way, could be used to support
substitutions (of one variable by a multiple of another, or "eager" ground
substitutions) in future.
-
( What about reified (& other?) constraints which may not need to suspend
on as many things as they used to once more information (e.g. the value
of the boolean) is known? )
-
It appears that it may be possible to do variable by variable substitutions
in a lazy fashion, in the same way that ground substitutions are done now
(during propagation variables are checked to see if they're ground and
if so, are substituted out). This would mean no special infrastructure
would be needed for substitutions, but would incur the overhead of performing
these checks on each variable accessed during propagation. It also may
not be compatible with techniques for efficiently propagating long linear
constraints (this would need further investigation).
-
With a modest amount of work it should be possible to have both standard
ECLiPSe and self-managed waking lists implemented, with a compile-time
switch between them, for comparison purposes.
-
( "Delayed suspension set-up" technique? )
-
Compile-time transforms
-
It is not clear how much benefit these actually bring. If the constraint
was compile-time transformable and all the things that looked like variables
at compile time are still variables at run time (i.e. they have not become
ground) then the benefit is clear. However, in order to limit the
amount of code duplication, the compile-time transformations as they stand
actually introduce a reasonable amount of overhead if the goal has to be
processed at run time. This is because the goal is first transformed
(sharing code with the compile-time transformations), and then the transformed
version call/1ed.
-
Unfortunately, many constraints are (re-)transformed at run time:
-
Constraints where compile-time transformation is not possible since part
or all of the constraint is unknown at compile time (e.g. constraints call/1ed,
constraints containing eval/1).
-
Constraints containing anything while looks non-linear at compile time,
in case it's actually linear at run time. In such cases compile-time
transformations are disabled in order to avoid the situation where the
transformation actually makes the constraint less efficient. (An
expression such as A*X compiled as a non-linear with A ground propagates
less efficiently than it would as part of a linear expression --- and also
less effectively if X appears elsewhere in the linear expression.)
Note that constraints containing "constants" which are not known until
run time are actually fairly common in "real" programs: constants are often
computed, read from a file, extracted from a table, passed in from some
other part of the program, etc.
-
Note that even with the above, it is possible for a compile-transformed
version of a constraint to be less efficient and less effective than that
same constraint processed at run time, if a linear constraint contains
two variables which look different at compile time but end up being the
same variable at run time.
-
We propose changing the focus to making sure that run-time processing of
a constraint is streamlined and efficient, with compile-time transformation
machinery assessed for its impact on:
-
The efficiency of any run-time processing (both when the transformation
was and was not possible at compile time).
-
The efficiency and the effectiveness of the constraints that end up being
set up at run time.
-
It would also be nice to get rid of the need for eval/1; this is an artifact
of the way compile-time transformations are currently handled. Apart
from simply abolishing compile-time transformations, eval/1 could be avoided
by making the constraints the transformations produce able to cope with
a run-time expression in any place they currently expect a variable.
-
Note that the efficiency of constraint set-up is of most importance during
search, and thus it is important to make sure that constraints which are
likely to be imposed during search (typically simple constraints with one
or two variables and a run-time constant) are set up efficiently.
-
Priorities
-
Floating point interval arithmetic
-
When propagating a constraint, all computation is done using interval arithmetic
performed on floating point bounds, even when the constraint is an integer
constraint. Compared to a pure integer solver, this is potentially
slower in two ways:
-
Computations are floating point rather than integer (we get almost no pipelining
of these computations at the moment, so latency is more important than
throughput, so integer operations would be expected to be faster even on
processors with good floating point throughput).
-
Computations are always done with both bounds when (for propagation) it
may be the case that only one bound is needed.
-
Note that neither of these overheads is simply wasted:
-
The use of floating point values deals very neatly with the integer overflow
problem; integer overflow would otherwise need to be trapped and dealt
with, likely requiring at least some code specific to each supported operating
system / processor combination (similar to the support needed for processor
rounding modes).
-
The "other" bound computed by the interval arithmetic, if not needed
for propagation, is used to check entailment; if a constraint is entailed,
it can no longer result in any propagation and may be killed, thus avoiding
any future (futile) propagations of the constraint. Note that:
-
Entailment usually cannot be detected without computing this "extra" bound.
-
For equations, it may be that only one bound is needed, but the standard
ECLiPSe propagation mechanism gives us no way to know which one it is or
whether both are actually needed.
-
Always computing both bounds keeps the code relatively simple...
-
I do not expect to change either of these things in the rewrite, mostly
due to the extra complexity they entail (having integer-based versions
of the propagation code with overflow trapping, or threading flags about
which bounds are of interest through all the places in the code which need
to know this information). Still, it may be worthwhile evaluating
how much (best case) potential speed benefit there might be in using integer
arithmetic, by creating quick-and-dirty pure integer versions of some of
the linear constraints (ignoring overflow) and comparing the results (Andy
Cheadle has already done some work along these lines).
-
Integer bounds (global constraints) & integer specialisation
-
"Strict" flag
-
In ECLiPSe 5.5, =< and < constraints were distinguished through the
use of a "strict" flag which was passed to each relevant constraint. This
flag (along with the reified boolean) resulted in a lot of if-then-else
code (the boolean featured because the negation of =<, which is not
strict, is >, which is strict; similarly with < and >=). This was a
problem for two reasons:
-
The code was hard to maintain, since there were many cases to write/update/check/test,
and evidenced by a number of bugs showing up later for various strictness
and boolean combinations.
-
The code was inefficient, due to the number of tests and amount of branching.
-
Most of these problems can be avoided, however, by observing that X <
Y is just the logical negation of Y =< X. Thus, by "negating"
the reification boolean associated with a constraint, any strict inequality
can be transformed into a non-strict inequality. In a similar fashion,
any =\= constraint may be transformed into an =:= constraint.
-
This problem is addressed by the reification
boolean toggle, with the constraint transformations and canonical forms
covered in the discussion of the equation/inequation
flag.
-
Constraint management
-
We believe there is currently too much "management" of constraints at the
ECLiPSe level: manipulating them, transforming them from one kind to another
(see constraint reduction), processing
results returned from C-level propagators, etc. Such management should
probably be kept to a minimum, and as much as possible done in C (at least
once the constraints are set up).
Design
A constraint goes through several layers / phases during its lifetime.
These are:
-
Preprocessing
-
Substitution of floats by bounded reals, constant folding, calling user-defined
functions, etc.
-
Transformation
-
Breaking the constraint up into primitive constraints (linear constraints
and nonlinear equations), putting them into normal form, etc.
-
Set-up
-
Creating suspensions, adding them to suspension lists, setting up any required
data structures, etc. This may be before initial consistency is achieved
(using the propagation phase for this) or after (saving the cost of this
set-up in the cases where attempting to achieve initial consistency would
detect failure or entailment).
-
Propagation
-
Waking up on relevant changes and maintaining consistency, etc.
-
Entailment
-
Cleaning up any remaining suspensions, etc. if the propagation phase detects
entailment.
Note that the above phases need not necessarily be distinct or in order
either in the code or during execution. For instance, the preprocessing
may be done as part of the constraint transformation, the preprocessing
and transformation phases may be split into compile-time and run-time components,
and constraint set-up may be done after the first propagation pass.
That said, the preprocessing and transformation phases ought to be largely
independent of the remaining phases. As a result, we choose to defer
the design of the preprocessing and transformation phases at this time,
and concentrate for now on constraint set-up, propagation and entailment.
We also concentrate on linear constraints, since, as noted earlier, they
are the most important; they may also be considered independent of the
non-linear equation constraints.
Internal representation of linear constraints
Linear constraints are (conceptually) stored as LinExpr op Constant
: Bool, where LinExpr is a list of Coefficient * Variable
pairs, op is a relational operator, Constant and the Coefficients
are ground numbers, Bool reflects the reification status of the
constraint, and the Variables are (non-ground) variables.
They are actually stored in a structure with the following fields:
-
Flags
-
Equation/inequation
-
= or =<
-
Integrality?
-
Shouldn't be necessary once constraint set up, but might be worth remembering
for printing?
-
Integrality
propagation
-
Whether the constraint could potentially propagate integrality to one of
its variables.
-
Reification
boolean toggle
-
The reification boolean var/value is the opposite of what is intended.
-
Reification boolean
variable
-
Reified boolean variable for the constraint.
-
RHS constant
-
The RHS constant of the constraint, either in "natural" form, or as integrality
flag + bounds.
-
Term count
-
Count of the number of linear terms in the constraint. Only needed
if the LHS term stored as a vector, but might
be useful for distinguishing 1, 2, and 3+ variable constraints.
-
LHS term vector/list
-
A vector or list of the linear terms on the LHS of the constraint.
Each term comprises:
-
Coefficient
-
Either in "natural" form, or as integrality flag + bounds.
-
Variable
-
Either as itself, or as its attribute.
Note that the flags, the RHS constant and the term
count just contain ground "numerical" data, and so could be placed
in a buffer structure. This is likely to save at least a little memory;
how much depends on other choices. It can also help with trailing:
the RHS constant and the term count are likely to change together, and
they can either be updated in place or copied to a new buffer depending
on whether there's been a new choice point since the last change.
Note that the integrality propagation flag
can also change (at most once) and need not (though it usually will) coincide
with a ground term elimination; if it needs to be trailed (rather than
in-place update) that could either be done separately or a new buffer created
(on the assumption that some other change is likely before the next choice
point which would have required the new buffer anyway, and the fact that
if not, the "damage" is limited since it can only happen once during a
forward execution).
Equation/inequation flag
This flag is part of the constraint
structure.
This flag addresses the "strict" flag performance
issue, in conjunction with the reification
boolean toggle.
This flag is fixed once the constraint is set up.
Ostensibly, this flag distinguishes between the different constraints
-
LHSLinExpr =:= RHSConstant
-
LHSLinExpr =\= RHSConstant
-
LHSLinExpr >= RHSConstant
-
LHSLinExpr =< RHSConstant
-
LHSLinExpr > RHSConstant
-
LHSLinExpr < RHSConstant
In practice,
LHSLinExpr =\= RHSConstant
|
is the logical negation of |
LHSLinExpr =:= RHSConstant |
LHSLinExpr >= RHSConstant |
is just |
-LHSLinExpr =< -RHSConstant |
LHSLinExpr > RHSConstant |
is the logical negation of |
LHSLinExpr =< RHSConstant |
LHSLinExpr < RHSConstant |
is the logical negation of |
-LHSLinExpr =< -RHSConstant |
and so (assuming the presence of the reified
boolean toggle) we just need to be able to distinguish between =:=
and =<.
Note that ECLiPSe 5.5 does not have just these two alternatives; it
also has <, through the use of a "strict" flag,
which complicates the inequality code considerably.
Integrality propagation flag
This flag is part of the constraint
structure.
This flag may be set (i.e. to 1) when the constraint is set up, but
after that it is only ever cleared (except for backtracking over such a
clearing).
A general discussion of this flag and its purpose can be found in the
section on integrality and propagation.
When the constraint is first set up, this flag is set only if:
-
the constraint is an equation (possibly of unknown reification status),
-
the constraint is not an integer (#) constraint and
-
all the coefficients and the RHS constant are integral.
It need not be set if:
-
all variables are already integral or
-
exactly one variable is non-integral and it has a unit coefficient (in
which case the variable should be constrained to be integral);
otherwise it should be set if permitted.
During execution, the flag is cleared if:
-
any variable has been grounded to a non-integer value or
-
the reification status is grounded in such a way that the constraint is
actually a disequation.
It may also (but need not) be cleared if:
-
all variables become integral;
otherwise the flag should not be cleared. It is expected that these
checks and clearings may be done in the course of normal constraint propagation.
If the flag is set and there is exactly one remaining non-integer variable,
that variable should be constrained to be integral if and only if:
-
the variable has a unit coefficient or
-
the constraint is about to ground the variable to an integral value.
Reification boolean toggle
This flag is part of the constraint
structure.
This flag addresses the "strict" flag performance
issue, in conjunction with the equation/inequation
flag.
This flag is fixed once the constraint is set up.
For any linear constraint, the user has the option of reifying the constraint
by providing a variable which reflects the status of the constraint (a
value 1 corresponding to entailment or enforcing of the constraint, 0 corresponding
to disentailment or enforcing the negation of the constraint). Assuming
that we wish to have one form of any constraint regardless of whether this
variable is set to 0, 1 or remains undefined, this means there must be
code to propagate both the constraint and its negation using the same constraint
format. This suggests there should be just one form used for both a constraint
and its negation, so that the same code can be used to propagate, say,
-
the constraint with reification boolean set to 1; and
-
its negation with reification boolean set to 0
since they are exactly the same constraint. If the value of the reification
boolean is provided at the time the constraint is set up then using just
one form of the constraint is easy to support: if one is given the "negated"
form, one simply substitutes the "normal" form and toggles the boolean.
If, on the other hand, the reification boolean is still a variable, one
could achieve the same effect by introducing a new boolean variable and
constraining it to be the negation of the one provided, but this introduces
an undesirable amount of overhead.
The alternative proposed here is to simply introduce a flag indicating
whether the reification boolean should be "negated" whenever it is used
or modified. This allows us to avoid introducing an extra boolean variable
and constraint, and incurs minimal overhead: any value read from or written
to the variable simply needs to be XORed with the flag. It also means we
only need two types of linear constraint (see the section on the equation/inequation
flag for more details).
Note that it would be nice for this toggle to occupy the "1" bit of
the flags word to facilitate its easy extraction for XORing.
Reification boolean variable
This boolean variable is part of the constraint
structure.
This variable may become instantiated after the constraint is set up.
This boolean is used to reflect or enforce the reification status of
the constraint. It is stored as a reference to the variable (rather
than a reference to the variable's attribute) since once the constraint
is set up, the attribute(s) of the variable are irrelevant; there are only
three states of interest: the value 0, the value 1 or still a variable.
Note that the interpretation of this boolean is modified by the reification
boolean toggle.
(Do a table?)
Numeric constant
Constants appear in several places in the constraint
structure.
Their representation addresses the coefficient
representation issue.
The value of these constants may or may not change, depending on the
nature of the constant and whether constraint
reduction is being performed or not.
Possible representations to be considered:
-
Native ECLiPSe format.
-
Integrality flag plus floating point bounds.
-
Integrality flag plus floating point bounds, except treat specially the
case where the bounds are equal so that only one floating point number
needs to be stored.
We intend to start by comparing options 1 and 2, with other alternatives
being considered later.
It turns out that the integrality of each individual constant in a linear
constraint need not be stored; a single integer
propagation flag is sufficient. See the section on integrality
and propagation for details.
RHS constant
This number is part of the constraint
structure.
This number is an instance of a numeric constant,
affected by the coefficient representation
issue.
This number may be modified after constraint set-up if constraint
reduction is being performed.
( Discuss representation? )
Term count
This integer is part of the constraint
structure.
This integer is only needed if the LHS terms are
being stored in vector form rather than list
form and constraint reduction is
being performed, but may also be useful for recognising the 1- and 2-variable
special cases when using the list form.
This integer may be modified after constraint set-up if constraint
reduction is being performed.
This integer should be stored adjacent to the RHS constant
so that they may be trailed together (since they will usually both change
at the same time).
This integer records the number of current entries in the LHS
term vector (or list).
LHS terms
This list or vector is part of the constraint
structure.
This list or vector may be modified after constraint set-up if constraint
reduction is being performed.
This list or vector records the variables appearing in the linear constraint
along with their coefficients.
The variables stored in this list or vector may become ground after
constraint set-up. If constraint
reduction is being performed then any such ground variables will be
removed from the representation.
We consider two basic forms for representing these linear terms:
List representation
This is the simpler of the two forms, and is what was in use before the
rewrite. The linear terms are stored in a list with one entry for
each term, recording its variable and coefficient. To save a little
space, rather than using a normal list, one can simply add an extra field
to the term structure giving the next term in the "list".
( Diagrams? )
The exact form of the structure depends on choices made in representing
the coefficient (see the section on numeric constants).
One option would be to have the term structure contain three fields: the
coefficient, the variable, and a pointer to the next term. With this
arrangement, looking at the tag in the coefficient field, one can distinguish
between a structure or buffer containing a "normalised" coefficient, or
any of the numeric types. This would allow us to efficiently support
multiple alternative coefficient representations by simply changing the
code which creates these terms; any code for accessing or modifying existing
terms would not need to change.
If constraint reduction is being
performed, removing a term from this representation is simply a matter
of setting the term's predecessor's "next" pointer to the same value as
that of the term to be removed, trailing the change. Note that this
representation is stable with respect to such changes; that is, the remaining
terms are in the same relative order as they were before the removal.
Vector representation
Management of this representation is a little more complicated than the
list representation, but it should be more efficient in terms of both speed
and memory consumption. Conceptually, it consists of a vector with
an entry for each term, recording its variable and coefficient. In
practice, however, the variables should go into a separate vector in order
to make the most efficient use of memory. This is because while the
coefficient data can be safely packed into a buffer structure without the
need for any ECLiPSe tags on the individual entries, this is not possible
for variables since the garbage collector needs to know the location of
all variables. To store the variables, there are two obvious choices:
-
Use a normal structure, with each field being a variable, complete with
tag.
-
Introduce a new type of structure for holding a vector of variables without
individual tags and teach the garbage collector about it. Note that
such a structure can only contain references to variables, where the "true"
variables (the self-references) reside elsewhere, since, due to their lack
of individual tag, they may not be referenced anywhere (in particular,
they may not reference themselves). (This is not a problem as such,
just something to be aware of.)
For representing the coefficient vector in a buffer, there are a number
of options available, depending on the choices made in representing the
coefficient (see the section on numeric constants).
One interesting possibility is to have separate vectors for the upper and
lower bounds of the coefficients; that way, if they are the same (e.g.
for any integer constraint - as long as the coefficients are exactly representable
as doubles) they can just both point to the same vector, saving space.
Where the vector representation gets interesting is when constraint
reduction is being performed. In such cases, when a variable
becomes ground we wish to eliminate it from the vector. Since we
cannot just delete an entry from the middle, the obvious thing to do is
copy the last entry in the vector to fill the hole. (Note that this
means that this representation is not stable: the relative order of terms
can change. It also means we cannot have any external references
to terms in the constraint that depend on their positions.) Since
the terms no longer fill the vector we need a term
count in order to keep track of where the currently valid terms end.
(This is necessarily distinct from the field in the vector's structure
which records the size of that structure (and hence, indirectly, the initial
number of terms in the constraint) for the garbage collector.)
In order to restore the constraint on backtracking, we need to have
kept information about the removed term. Rather than store this in
the trail, we can store it in the newly unused location at the end of the
vector. That is, rather than simply overwriting the eliminated term
with the last one, we can exchange their positions in the vector instead;
on backtracking we just exchange them back. (Note that if the upper
and lower bounds of the coefficients are in "separate" vectors which may
be shared if identical, we need to avoid swapping the same data twice in
the shared case. Note also that we need to swap the "raw" variable
references, without any dereferencing, so that the references remain safe
on backtracking.) Better yet, on backtracking we don't need to swap
the terms back, we can just leave them where they are: if we were free
to move the last term to fill the hole, there's no reason we can't just
arbitrarily rearrange them as we like. This means we only need to
trail the old values of the RHS constant and the term
count; moreover, we only need to do this once for a sequence of reductions
performed when propagating the constraint (e.g. if a number of variables
have become ground since the last time the constraint was propagated),
which probably means there's little benefit to be gained from doing timestamping
on this data. (Though if we put them in a separate (buffer) struct
from the main constraint struct and trail the struct rather than the individual
fields, we get timestamping anyway.)
Note that if multiple variables might have become ground, the following
algorithm moves all the non-ground terms to the front of the vector with
the minimal number of swaps:
-
Scan forward through the vector until the first ground term is found or
the end of the constraint is reached.
-
Scan backwards through the vector until the first non-ground term is found
or it meets the forwards scan.
-
If the scans have not crossed, exchange the terms and repeat, continuing
the scans from their current positions.
It ought to be possible to incorporate the above into most propagation
algorithms (at least, the ones which proceed through the constraint in
a linear fashion but don't really care about the order); it simply corresponds
to processing the terms in the order they are after the swaps rather than
the order they were in before them.
Internal representation of variable attributes
This is expected to be more-or-less the same as the current version
of IC, though we may consider moving some of the fields into a buffer structure
or something, and we will be considering alternative representations of
(holey) integer domains. Where practical, access to a variable's
data should be mediated through a set of access routines, in order to facilitate
trying different alternatives.
-
Flags
-
Integral
-
Lower bound
-
Upper bound
-
Finite domain
-
Representation of holey integer domain, if required.
-
ID?
-
Could be useful for giving stable variable sort order (which is useful
for detecting multiple occurrences of a variable).
-
[waking lists]
-
Linear constraint set-up
I envisage having a single predicate (implemented in C as far as practical)
for setting up any linear constraint. The input constraint is conceptually
of the form LinExpr op 0 : Bool, where LinExpr is a list
of Coefficient * Variable pairs, op is a relational operator,
the Coefficients are ground numbers, Bool reflects the reification
status of the constraint, and the Variables are (non-ground or ground)
"variables". The representation of the constraint would be via the
following fields:
-
Operator
-
=:= or =< (or perhaps any of <, >=, >, =\=?).
-
Integrality flag
-
Whether to impose integrality on all the variables and coefficients.
-
Reification boolean
-
Whether the constraint is to be imposed, negated, or unknown.
-
Reification boolean toggle
-
Whether the reification boolean is the opposite of what is intended.
(Not needed if the full set of operators allowed.)
-
Linear expression
-
A list of linear terms of the form A*X where A is ground but X may be a
variable.
The integrality flag and reification boolean toggle could be incorporated
into the operator field by allowing the full set of relational operators
(including all the # operators), but this does not seem useful since (prior
to calling this set-up predicate) the operator will already have been examined,
at which point it might as well be decomposed fully. (See the equation/inequation
flag section for a description of how the operator and boolean toggle
fields relate to each other.)
The set-up predicate would be responsible for making sure that all variables
are constrained to be IC variables, and, if integrality is required, imposing
integrality on all the variables and checking the integrality of all the
constants. It would also construct the internal
representation of the constraint, enforce initial consistency and set
up any required suspensions, etc. (though the initial consistency and suspension
creation may be delegated to the propagation code if appropriate).
Linear constraint propagation
As discussed above, we want to avoid having a constraint change its form
(or really, its suspension) over its lifetime. Hence the same predicate
will be called with the same format of data to restore consistency for
all kinds of linear constraints. However, it is expected that, for
efficiency, it will be worth distinguishing between:
-
one variable constraints
-
two variable constraints
-
These would use the obvious simple algorithms, and would probably also
specialise based on the operator and/or the state of the reification boolean.
-
longer constraints
-
These would use the "two-pass" propagation algorithm or one of its variants
(except for =\= constraints, which warrant a different algorithm), and
may use general code regardless of the operator and reification boolean
state.
It is expected that always storing the constraints in general form and
performing some switching to choose the correct specialised algorithm will
result in little overhead, and will allow constraint reductions to be performed
without the high cost of creating and destroying suspensions that is currently
incurred.
( Talk about how we always use floating point interval math even when
it's not needed; discuss possible alternatives? )
Integrality and propagation
In earlier versions of IC, it was important to know whether each intermediate
result was integral or not, in order to improve accuracy: bounds which
should be integral but did not have an integral value due to floating point
computations could be rounded in to the next integral value. With
the use of processor rounding modes, however,
this is no longer necessary: if a bound should have an integral value,
it will have one. This means the only thing integrality of coefficients,
intermediate results, etc. is needed for now is deducing when a non-integer
variable should be constrained to be integral. For example, consider
the constraint
X + Y =:= 3
If Y is an integer variable, then this implies that X should be an integer
variable too, since giving Y any value from its domain will result in an
integer value for X. In this case, the integrality of X can be deduced
just from looking at the constraint and the types of the variables appearing
in it. In other cases integrality may depend on the values taken
by the variables:
2 * X + Y =:= 3
In this case, X is integral only if Y ends up being fixed to an odd number.
Some observations about integrality:
-
A constraint can only propagate integrality to a variable if it's an equation,
and hence could assign a specific (potentially integer) value to that variable
(e.g. when all the other variables become ground).
-
A constraint can only propagate integrality if all the coefficients and
other constants in the constraint are integral; if this is not the case,
then any computed value it wants to assign will not be integral.
-
If all variables are already integral (or become so later), there's nothing
to do. :)
-
If any variable gets grounded to a non-integer, the constraint cannot impose
integrality on any other variable.
-
If more than one variable is not (yet) integral, then no integrality can
be inferred (yet). E.g. if two are still real then the constraint
may be satisfied by assigning non-integer values to them both.
-
If there is a single non-integer variable and all the other conditions
are satisfied, then we have two cases depending on the value of the coefficient
of the non-integer variable:
-
1 or -1: the variable may be constrained to be integral immediately
-
any other value: the integrality of the variable may depend on the values
the other variables take and thus cannot be determined (at least, not efficiently)
until the constraint is about to assign it a value.
( Talk about variables getting values from bounds (all remaining variables
grounded simultaneously) vs values (value of last variable computed based
on (externally-supplied) values of other variables)? )
This means we don't need to store the integrality of the coefficients
or the RHS constant; instead we can have one flag for
the whole constraint, indicating the potential for integer propagation,
which is cleared as soon as we know that no such propagation can occur.
Then if the flag is set and when propagating the constraint we discover
that there is one remaining non-integer variable and it has a unit coefficient,
or if we're about to ground the only-remaining non-integral variable to
an integral value, we impose integrality.
Flag for "potential int propagation". Set initially iff (non-integer)
eqn, all coefs integral, at least one var not integral (if only one non-int
var and it has unit coef, simply make the var int and clear the flag).
If any var grounded to non-int value, clear flag. If notice all vars
integral, clear flag? If notice only one var non-integral and has
unit coef, make integral. If grounding last var and flag set and
value integral, make var integer.
It would be nice to have integrality propagation separate from the bounds
propagation, so that it doesn't have to be incorporated into every bounds
propagation algorithm we implement and so that it need not impose any overhead
if it's not needed (many constraints in practice won't need it).
But a separate propagator makes it hard to ensure a variable is made integral
(if appropriate) before being assigned a value by the main propagator.
Linear constraint entailment
( Talk about detecting entailment, killing suspensions, leaving delayed
goals if the constraint is ground but not properly entailed, etc. )
Other notes / ideas / etc.
Have IC code call i_add, i_mul, etc. directly rather than going through
ec_ria_binop.
Try to avoid setting up the constraint if first propagation pass is
all that is ever needed.
Experiments
This section describes the experiments performed and evaluates the results.
Attributes vs. variables
-
Aim
-
Assess the overhead of dereferencing variables to access their attributes
as part of the constraint propagation process.
-
Assumptions
-
We will only look at linear constraints; the overhead is expected to be
similar for other kinds of constraints.
-
We will assume that the linear constraint is stored as a list of Coefficient
* Variable terms. Other ways of storing or representing the linear
terms may have somewhat different overheads, but for the most part we're
interested in the amount of extra overhead introduced by the variable representation
so this should not be a problem.
-
We will assume that the number of linear terms in the constraint does not
affect the average dereferencing cost per variable, so that we can use
suitably long constraints in order to help obtain a reasonably measurable
CPU time.
-
We will assume that the arrangement of the variables and terms, etc. in
memory does not affect the average dereferencing cost per variable, so
that we need not do anything fancy when setting up the constraints.
(Note that this is probably the most dubious assumption; it may be worth
doing some kind of randomised arrangement.)
-
We will not look at linear constraints containing ground "variables", though
the overhead of detecting/accessing these for each representation may also
be worth looking at (though the attribute version is likely slower than
the variable version).
-
Method
-
Construct a list of variables such that the reference to each variable
in the list is the head of a variable chain of desired length (e.g. 5,
10, etc.).
-
Give each variable an IC attribute.
-
Construct a list of Coefficient * Attribute terms and a list of Coefficient
* Variable terms based on the list (using, for example, 1.0 as the coefficients).
-
Check that, for the Coefficient * Variable list, the variables do actually
need dereferencing the appropriate number of times.
-
For the Coefficient * Attribute list, perform a number of passes over the
list, extracting one of the fields of the attribute (e.g. the lower bound),
and record the total time taken.
-
Repeat for the Coefficient * Variable list.
-
Adjust the length of the "constraint" and the number of passes over the
list in order to get a run time which is large enough to be measureable.
-
Repeat the experiment for a number of different variable chain lengths
and a number of different architectures.
-
Results
-
dog, using 5.6 #6
|
Attribute |
Variable (1) |
Variable (5) |
Variable (10) |
access lwb, 1000/1000 |
0.58 |
1.26 |
1.58 |
2.14 |
nodbgcomp, access lwb, 1000/1000 |
0.47 |
0.88 |
1.44 |
2.45 |
-
Note the last column! Turning off debugging slows it down!
This symptom also reproducible on alpha_linux: turning off variable names
results in dereferencing the reference chains taking about twice as long!
-
-
Without more information about typical variable chain lengths and how often
IC variables get unified, there's no clear result here. It shouldn't
be too hard to collect statistics on this kind of thing, and we probably
should do this at some point, but since it should be relatively easy to
have both alternatives implemented, the conclusion is to just implement
them both and try them out on real programs to see how they do.