Add a mathematical constraint system#8816
Conversation
tlively
left a comment
There was a problem hiding this comment.
I highly recommend explicitly framing the constraint space as a lattice:
- Both
and_andfuzzyOrare effectively merging constraints. You want both (but especiallyfuzzyOr) to have all the properties of a lattice join operator: monotonicity, associativity, commutativity, idempotency, etc. You also wantfuzzyOrto be as precise as possible; it has to lose some precision sometimes, but you only want it to lose as much precision as necessary given the representation of constraints. So you want it to be a least upper bound, i.e. a join. - Making the constraint space a lattice will give you all the nice properties you want for using it in a program analysis: order-independence, guaranteed convergence, etc. It also reduces all the novelty and complexity to just generating the constraints in the first place; getting to the fixed point after that is just the classic worklist + graph traversal pattern.
- Making the constraint space a lattice will let you test it in the lattice fuzzer, which can do a better job than just unit tests alone of making sure it has all the properties we want, including that we do not unnecessarily lose precision in the merge operation.
|
|
||
| // We limit constraints to a low number to ensure good performance even with | ||
| // simple brute-force solving. | ||
| // TODO: use a generic constraint solver..? |
There was a problem hiding this comment.
I did have that POC for pulling in Z3. In the limit I guess that's what we'd want. 5c2bbb7
| // { this } => { condition } | ||
| // | ||
| // https://en.wikipedia.org/wiki/Material_conditional#Truth_table | ||
| Result check(const Constraint& condition) const; |
There was a problem hiding this comment.
Hmm, yeah. Another option is eval as @MaxGraey suggests?
There was a problem hiding this comment.
Thinking more on this, I think that x.implies(y) is not quite right, as it reads as 'x implies y' i.e. x is not checking if it implies y, but sounds like a new constraint, that makes x somehow imply y. Ditto for x.proves(y).
So checkImplies might work, but is longer than check? I am leaning towards check or eval.
There was a problem hiding this comment.
I would strongly prefer some variant of proves or implies. I don't think those names sound like they're adding new constraints, but something like checkImplication would also be fine with me. eval does not suggest the correct operation to me.
There was a problem hiding this comment.
(FWIW, the tests use "proves" in their comments)
There was a problem hiding this comment.
Ok, I don't feel strongly. Renamed to proves.
|
That's awesome! Have you considered more academic and conventional naming for lattice-like stuff?
or something like this? |
|
@tlively Definitely making this a lattice would have benefits, but it would add overhead and complexity, I worry. Specifically, having a limited capacity (number of constraints in a set), as in the current design, is really nice for efficiency, but makes it not a lattice. Here is a concrete example. For a lattice we need this absorption law:
which breaks the absorption rule. (This is sort of parallel to the issue with multiple constants in possible-constants - we only support one constant, not an arbitrary number. An arbitrary number is necessary for all the nice mathematical properties we want, but the overhead isn't worth it in GUFA.) |
Good idea, I think that makes sense.
I think this is clear enough already, and shorter?
I left this intentionally vague as this may expand in the future. A set of constraints is, atm, a conjunction, but if we find a nice way to allow OR and not just AND, we should add it. The idea is, conceptually, a set of constraints that can prove things. |
|
Btw binaryen already has some basic semi and full lattices: https://github.com/WebAssembly/binaryen/blob/main/src/analysis/lattice.h and https://github.com/WebAssembly/binaryen/blob/main/src/analysis/lattices/abstraction.h infra. So how about this? class LowerBound : Lattice { ... }
class UpperBound : FullLattice { ... }
class RangeBound : FullLattice { ... } |
|
No, that's exactly correct. See the parenthetical note I added to my previous comment in an edit. And it's mostly fine that it's a semilattice because the generic worklist algorithm that propagates information to find a fixed point only does joins. The only catch is that the transfer function will use |
|
Ok, good, then we are on the same page - this is not a lattice, so we lose all the nice properties that a lattice normally has. That leaves the code factoring as a possible benefit. But when I ran Gemini on this, I didn't see a code benefit either - mostly a bunch of new boilerplate to fit into the Lattice framework. Unless you have a way to do this without boilerplate that actually reduces code rather than adds? |
|
#8821 and #8824 show the generic lattices we could add. Obviously the code is more complex if you count the heavily-templated lattice implementations, but I don't think that's the right way to look at it. Even supposing that we never reuse the lattices for anything else (although we could!), factoring the constraint system into composed lattices makes it much easier to focus on the interesting things and abstract away all the complexity around managing our knowledge of independent constraints. It also makes the code much more unit-testable and fuzzable. |
I agree. But, ignoring the generic template code, is it actually shorter than my current code? I'd like to look at that diff if you have it. |
|
I thought a bit more about how this could work. Here's a rough sketch: Term =
| Bottom
| Interval(min, max) (* it's more generic instead to have separated lower/upper/range bounds *)
| TopSingle predicates Let's check how we can represent compound (range-like) predicates:
The powerset of intervals is quite tricky, and I recommend skipping it for now. It can lead to infinite growth. // or perhaps use std::variant + std::get_if?
enum class TermKind { Bottom, Interval, Top };
struct Bound {
int64_t value; // it can be abstract value as well
bool negInf, posInf;
// bool inclusive; // open / closed ?
};
struct Term {
TermKind kind;
Bound lower, upper;
static Term top();
static Term bottom();
static Term interval(Bound lo, Bound hi);
};
struct IntervalLattice final : FullLattice<Term> {
Term top() const override;
Term bottom() const override;
bool leq(const Term& a, const Term& b) const override;
Term join(const Term& a, const Term& b) const override;
Term meet(const Term& a, const Term& b) const override;
};
struct IntervalLatticeSolver {
Term eval(const Term& current, const Predicate& pred) const; // or refine
}
Term IntervalLattice::meet(const Term& a, const Term& b) {
if (a.isTop() || b.isTop()) return top();
if (a.isBottom() || b.isBottom()) return bottom();
auto lo = max(a.lower, b.lower);
auto hi = min(a.upper, b.upper);
if (lo > hi) {
return bottom();
}
return Term::Interval(lo, hi);
}
Term IntervalLattice::join(const Term& a, const Term& b) {
if (a.isTop() || b.isTop()) return top();
if (a.isBottom()) return b;
if (b.isBottom()) return a;
auto lo = min(a.lower, b.lower);
auto hi = max(a.upper, b.upper);
return Term::Interval(lo, hi);
}
bool IntervalLattice::leq(const Term& a, const Term& b) {
return subset(a, b);
}
Term IntervalLatticeSolver::eval(const Term& current, const Predicate& pred) {
...
} |
|
@MaxGraey Intervals or ranges can work that way, yes. But as I wrote above, I think we should support more than that, things like |
|
|
As mentioned above, using separate lattices adds overhead (either a product lattice, or multiple passes one for each). Also, these things can interact: imagine a range I suggest we start with the approach I have here. As I said above, not a lot more code remains past this initial PR, for us to get to useful optimizations. And we can always reconsider and replace it all with a lattice later if it gets messy - the pass that will use this will not depend on the details of the constraint solving. Here is draft and unpolished code for that pass, hopefully enough to see that this will be used very simply: https://github.com/kripken/binaryen/blob/constraint/src/passes/RangeAnalysis.cpp |
I don't have a full diff (this PR would have to define Beyond the complexity wins, this would make it trivial to experiment with different points in the performance/precision trade off space, e.g. by using |
I really think it would be valuable to see that diff. When I tried to produce it, as I said above, I didn't like the result due to boilerplate. But maybe I was doing it wrong. This is your idea, so you will be able to implement it best, and then we can evaluate it - does that make sense? If you don't have time, another option is to land this, and consider your idea later. It will be a drop-in replacement for the code in this PR, as used by the future pass (as can be seen in the draft version of that pass), so landing this is not locking us into anything.
I don't follow that. In the current PR it is also trivial to adjust the constant or even make it unbounded. |
|
Sure, I can implement |
|
Ok, the draft PR adding a general |
|
FWIW, I'm thoroughly convinced that there's no need to use a proper lattice here. I'd even be fine not using a join semilattice if we really wanted to support EQ and NE constraints. The main thing that would make me happy would be if we could compose this utility out of general parts, e.g. separating |
|
Ok, thanks for that code, that definitely helps me understand your point of view. Yeah, it's unfortunate we can't use a proper lattice here. But limited capacity is just necessary, and goes against the point of a lattice. And I think we do need So what is left is your point about
I agree to that in general. But does this PR not do that already? Look at how short and sweet
Put another way, the current code in this PR is really, a minimal mathematical constraint system. It just does that, and in a simple and modular way. I am not opposed to a larger amount of code, as in your PRs, if there is a benefit. I still don't see what it is, though? The key And, look: in the end, a mathematical constraint system must do math. Math is not perfectly separable into composed lattices. If we want to do |
|
The main benefit of a lattice idiom is it provides a well-defined merge/join operation for dataflow analysis, guarantees convergence and makes it easier to compose. It also gives a common API and semantically defined behaviour that can be reused for other lattices. On upstream's lattices folder alread implemented inverted lattice which can use for invert conditions such as if (!(x >= 5 && x <= 10)). That's a good case how lattices can be composed and stacked. Also it seems we already have machinery for abstract eval such compositions of sub-lattices: https://github.com/WebAssembly/binaryen/blob/main/src/analysis/lattices/abstraction.h#L34 |
|
@juj btw it would be interesting to hear what you think about all this |
| if (auto* aConstant = std::get_if<Literal>(&a.term)) { | ||
| if (auto* bConstant = std::get_if<Literal>(&b.term)) { |
There was a problem hiding this comment.
Please use helper functions heavily here. If each function just does some case analysis and forwards each case to a helper function, then there's much less state to keep in mind as we read through the code. In contrast, right now I have to remember the state of multiple outer ifs and switches to know what case the code is supposed to be handling.
There was a problem hiding this comment.
Done, avoided nesting and added a helper. Code is much simpler now.
| // If this is already implied by current constraints, then it is redundant. | ||
| // E.g. if we are { x = 10 } and other is { x >= 0 } then all we need is | ||
| // { x >= 0 } as the result of the OR. | ||
| if (eval(other) == True) { | ||
| *this = other; | ||
| return; | ||
| } | ||
| if (other.eval(*this) == True) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
This doesn't handle the case where the constraints can be relaxed in both directions separately. For example:
{ x >= 2 /\ x <= 4 } \/ { x >= 1 /\ x <= 3 }
This should give { x >= 1 /\ x <= 4 }, but right now it just gives up.
This might be included in the // TODO smarts below, but I think it's important to see the full complexity here so we can get it factored as nicely as possible.
There was a problem hiding this comment.
What do you mean by "see the full complexity"? I'm not sure what you are asking this PR to do aside from have the existing TODO.
There was a problem hiding this comment.
I'm suggesting we resolve the TODO :)
But I guess I can just say in advance that the simplest way to do this will be in terms of a fuzzyOr operation on a pair of Constraints, so I guess we don't need to do it now.
| // A constraint: some operation and some value, like "is equal to 17" or "is | ||
| // less than local 6". | ||
| struct Constraint { | ||
| Abstract::Op op = Abstract::Invalid; |
There was a problem hiding this comment.
I don't think there's much value in making invalid constraints representable (nor in reusing the Abstract enum). How about using a new enum that can be specific to this use case that does not have an Invalid member?
There was a problem hiding this comment.
Reusing Abstract is useful because we have code to parse IR into it. E.g. we need to parse AddInt32 into Abstract::Add (the next PR does this).
Without this reuse, we'd need to duplicate that code, or add a mapping of Abstract into a new enum.
I think this is exactly what Abstract is meant for: an abstract operation, without the details of a Type. This is precisely the right level of abstraction for a mathematical constraint system, mirroring the == etc. notation in math.
There was a problem hiding this comment.
Makes sense to reuse the parsing code 👍
There was a problem hiding this comment.
We can still avoid adding Abstract::Invalid, though.
| // { this } => { condition } | ||
| // | ||
| // https://en.wikipedia.org/wiki/Material_conditional#Truth_table | ||
| Result check(const Constraint& condition) const; |
There was a problem hiding this comment.
I would strongly prefer some variant of proves or implies. I don't think those names sound like they're adding new constraints, but something like checkImplication would also be fine with me. eval does not suggest the correct operation to me.
| Result eval(const Constraint& condition) const; | ||
|
|
||
| // Check an entire other set. | ||
| Result eval(const AndedConstraintSet& other) const { |
There was a problem hiding this comment.
Let's put this implementation in the .cpp file as well.
| // Add a constraint to the set, ANDed with the others. The caller must make | ||
| // sure not to add too many (i.e. it is invalid to call this when full()). | ||
| void and_(const Constraint& c) { |
There was a problem hiding this comment.
It would be nice to avoid burdening the caller with making this decision. We could just arbitrarily drop extra constraints, or allow the user to supply a heuristic for determining which to keep. The end result will be the same if we remove this burden from the caller, since the caller will otherwise have to make the same decisions.
There was a problem hiding this comment.
Interesting, yeah, maybe that is better. Should it then be fuzzyAnd..?
There was a problem hiding this comment.
Yeah. Maybe "bounded" or "approximate" instead of "fuzzy?" Or alternatively we could use "join" and "meet" language to differentiate these operations from the precise logical connectives.
| } | ||
| } | ||
|
|
||
| return Unknown; |
There was a problem hiding this comment.
| return Unknown; | |
| TODO: handle >, >=, <, and <= | |
| return Unknown; |
| if (currResult == Unknown) { | ||
| // If something is unknown, it all is. | ||
| return Unknown; | ||
| } |
There was a problem hiding this comment.
IIUC, proving one of the other constraints False can take precedence over Unknown.
| // If this is already implied by current constraints, then it is redundant. | ||
| // E.g. if we are { x = 10 } and other is { x >= 0 } then all we need is | ||
| // { x >= 0 } as the result of the OR. | ||
| if (eval(other) == True) { | ||
| *this = other; | ||
| return; | ||
| } | ||
| if (other.eval(*this) == True) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
I'm suggesting we resolve the TODO :)
But I guess I can just say in advance that the simplest way to do this will be in terms of a fuzzyOr operation on a pair of Constraints, so I guess we don't need to do it now.
| // A constraint: some operation and some value, like "is equal to 17" or "is | ||
| // less than local 6". | ||
| struct Constraint { | ||
| Abstract::Op op = Abstract::Invalid; |
There was a problem hiding this comment.
We can still avoid adding Abstract::Invalid, though.
This allows defining constraints like
{ x >= 0 && x <= 100 }and to then check if theyimply something else is true or false, like
{ x >= 0 && x <= 100 } => { x < 9999 }(example of a valid inference).
This is the minimal first part of such a system, focusing on
==, !=, and very simplesolving. Putting up for design feedback before I work in depth on the rest.
Next steps are to add
>=, <etc., and to add a pass that uses this in a control-flowaware way, that is, the goal is to optimize things like
This is important to remove userspace bounds checks for Kotlin (and likely Java).
inplace_vectorpart here is from #8814 (will rebase once it lands).