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?
|
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 { ... } |
Certainly you cannot have all three of these properties:
We both agree that we must give up on (3). I'm just saying that we should design the system such that we can still have (1) and (2) instead of just (2). Designing the system to be a lattice will require some care and perhaps some additional compromises on precision, but I strongly believe the benefits would be worth it. One option would be to come up with all the constraints we're interested in, then figure out how to structure them properly to make sure they form a lattice. Another way to keep things simple would be to use the product of multiple simpler lattices for the analysis. For example, we could simultaneously do a range analysis, sign analysis, and bit analysis, each of which is very simple to understand. Each individual component of the larger lattice could be developed independently. Otherwise we should just pull in Z3 rather than reinventing the wheel for an arbitrary constraint solver. |
Here are the ones we know we want, from direct user feedback:
And possibly other things that are common in branch conditions (the above three are all derived from that). E.g. if The product of lattices for each of those things grows large quickly, so I worry about overhead there. Z3 is definitely an option in the long term, but (1) we need only a tiny subset of it, and (2) we really want a wasm constraint solver, and implementing wasm-isms in Z3 (like subtyping etc.) may be difficult/inefficient. To be clear about the scope: This rather small PR adds So all this will remain quite small and focused: this is not a big project! |
|
The most trivial way to turn the current constraint system into a lattice is to define a total order on
This logic can be abstracted out into a generic (Note that BoundedConjunction would be a join semilattice, not a full lattice. But that's sufficient for our purposes. We probably should choose a name other than |
|
I don't quite see how this would work - again, the problem is the capacity, which is bounded - but I figured that maybe I'm just not seeing it? So I asked Gemini, giving it this PR's code + an overview + your last comment. It wrote a bunch of code, ending up with a semilattice - not a lattice. Here is Gemini's explanation when I asked why: > You wrote `BoundedConjunction is a join-semilattice, but not a full lattice.` why is it not a full lattice?
▸ Thought for 8s, 480 tokens
The user is asking why `BoundedConjunction` is not a full lattice.
BoundedConjunction<L, N> is not a full lattice (specifically, not a meet-semilattice) because the meet (greatest lower bound, corresponding to logical AND) does not always exist due to the size limit N.
In a full lattice, for any two elements A and B, there must exist a unique greatest lower bound A land B (meet) such that:
1. (A land B) ≤ A
2. (A land B) ≤ B
3. For any C where C ≤ A and C ≤ B, then C ≤ (A land B).
For a conjunction of constraints, the natural meet is the union of the constraints (e.g., {x = =5} land {y = =10} = {x = =5,y = =10}). This represents a state that is more specific (lower in the lattice)
than both.
However, in BoundedConjunction<L, N> , if the union of constraints exceeds the size N, we are forced to discard some constraints to stay within the limit.
Discarding a constraint makes the set less restrictive (larger in the lattice, closer to Top). As a result, the bounded approximation of the meet is no longer guaranteed to be a lower bound of the inputs.
### Example
Let N = 1, and we want to find the meet of:
• A = {x = =5}
• B = {y = =10}
1. The true mathematical meet is {x = =5,y = =10}.
2. Since N = 1, we cannot represent this. We must discard one constraint, say we keep only {x = =5}.
3. Let our approximated meet be M = {x = =5}.
4. For M to be a valid meet, it must be a lower bound of both A and B (M ≤ A and M ≤ B).
• M ≤ A holds because {x = =5} ≤ {x = =5}.
• M ≤ B does not hold because {x = =5} not ≤ {y = =10} (knowing x = =5 does not imply y = =10).
Since we cannot construct a valid lower bound for all pairs of elements, the meet operation is not closed under this bounded representation, making it a join-semilattice (where the join/OR always exists,
defaulting to the empty set/Top if no common constraints are found) but not a full lattice.Am I and Gemini wrong? |
|
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. |
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).