I'll attempt to explain an approach I came up with to address simple context sensitivity in Demand CFA.
First some code to analyze.
fun do-something(f)
f(5) + f(10)
fun main()
do-something(fn(x) x + 2)
do-something(fn(y) y + 1)
This is your typical example of call sensitivity. The purpose of call site sensitivity is to distinguish between multiple bindings of the same parameter, to allow for more precise analysis. In this case, we want to distinguish between the two bindings of f
in do-something
as well as the multiple bindings of x
and y
. In an exhaustive CFA we could choose to parameterize the analysis by the call site, but due to the dynamic nature of call sites and higher order functions we discover the different call sites as we do the analysis.
From a demand perspective, if I want to know the value of the expression f(5)
, first I need to figure out what values f
can take on. Using demand CFA's caller relation we find both lambdas. Now we need to evaluate the body of the lambdas to figure out the value of the expression. The bodies references x
and y
. To find the value of x
in a call site insensitive analysis we would typically find all bindings of x which would include 5
as well as 10
. The result of the expression f(5)
then would include the result for the expression f(10)
as well. This seems nonsensical. The query clearly delineated a context we care about. We want to know the value of f(5)
not f(10)
. However, full context sensitivity is often too expensive.
The idea I have is to keep track of bindings (as expressions / thunks) as we descend into a call, then when evaluating a reference to a parameter we know about in our bindings, we can directly evaluate that expression binding.
With the above example we still evaluate f
to both lambdas, but when evaluating the bodies of the lambda we find that we already know the expression that x
/y
is bound to. Thus we have a selective context sensitivity - one where the evaluation preserves call / parameter binding relations, but only for calls we have made during the abstract evaluation. Note that the exhaustive formulation of CFA also preserves these relations between functions and parameters, at least until the second time the lambda is called, at which point the joining happens.
Question: How does this work in the presence of recursion
Okay lets consider what modifications might be needed in the presence of recursion / mutual recursion. Let's consider the following example.
fun map(l: list<int>, f: (int) -> int): list<int>
match l
Nil -> Nil
Cons(x, xs) -> Cons(f(x), map(xs, f))
fun main()
map([0, 1, 2, 3], fn(x) x + 1)
Hovering over f
should result in just one closure. However, evaluating the expression f(x)
should ideally result in just an abstract number (I know, not any more interesting than the type system). However, a more precise analysis could determine the range of numbers using an interval abstraction. However, let's take the general map statement in main: what do we have to do to make this particular sensitivity not diverge in the presence of recursion? When we evaluate the call to map
, we bind the parameter l
to the expression [0, 1, 2, 3]
and f
to the closure fn(x) x + 1
. Then when evaluating the body, we meet the recursive call to map
. If we keep binding the expressions an recurring, we could potentially diverge (I know, not in this case, but in the general case of non-inductive datatypes or recursion on numbers etc). Even on finite domains, unrolling recursion too much could result in long analysis times. So let us consider what we should bind to the parameters to prevent blowup. Let's consider widening the parameters by joining the abstract values of the parameters. This would result in the following bindings: f: eval(ref/f) join f
, l: eval(xs) join l
. Let's assume the abstraction of the ADT(abstract data type) for l
ends up looking like: {Cons(0, Cons(1, Cons(2, Cons(3, Nil))))}
. Then lets assume that xs
is correctly abstracted to {Cons(1, Cons(2, Cons(3, Nil)))}
. We have two paths forward, bind the concrete (unroll recursion), or widen. If we can determine that the size
of the datatype is decreasing, and we have enough allotted time left, we could keep evaluation with precision. However, we can't know that ahead of time, (TODO: consider timeouts and other estimation or revisiting techniques). So let's widen. Let's consider some alternatives for joining l
with xs
:
Most precise (other than just putting into a set) {Cons(AbNum, Cons(AbNum, Cons(AbNum, {Cons(AbNum, Nil), Nil})))}
- This has the same issue as before, a long list could take a long time to evaluate (though this is still somewhat limited by the size of the syntax)
- Evaluating this one step results in another
l: eval(xs) join l
binding, which makes the abstraction larger in some ways (depending on how many alternates){Cons(AbNum, {Cons(AbNum, {Cons(AbNum, Nil), Nil}), Nil})}
. This is not tenable
So lets consider just widening to: {Cons(AbNum, self), Nil}
where self just refers to the evaluation of the binding itself. With this approach on the second unwinding we find the evaluation already in the cache, and we can just return the value.
Two issues with this approach:
- Precisely defining the widening
- Defining the cache (so that it takes into account parameters properly)
My current naive approach (not handling recursion) keeps the parameters separate from the configuration in the cache. This has a few issues:
- Parameters are assumed to be only the bindings from the initial call (in a recursive call)
- A recursive call would cause an infinite loop
- I detect when I can use parameters by whether
bind
returns the directly enclosing lambda. (This is problematic since it doesn't check if I'm in a recursive context)
I need to adjust the memoization approach:
- Add a bottom value to the cache prior to evaluation (just on first visit?)
- Make sure this value has a cacheGeneration that will guarantee a single recursive evaluation
On the recursive call, the memoized results of the cache will determine that this is a recursive call (no actual results in the cache), and will join the parameters prior to evaluation. This means that I have to adjust my function that inserts the params to first check if the function I'm evaluating is in the cache. Also, I will have to migrate away from the syntactic check for parameter scope.
Okay now for formalizing the widening / joining of the abstract constructors. For two abstract values
- Functions are joined by making sets of lambdas (without bindings)
- Constructors are joined:
- If they begin with the same constructor recur
- Join all constructor parameters
- If it is a recursive parameter (with same type variables etc)
- If it is the same constructor, recur
- If it is a different constructor, add the alternate to the top level set of constructors and set the parameter to a self value.
- If it is not a recursive parameter, create a new join context, and join the parameters (self in a recursive constructor that isn't the primary constructor refers only to terminating the recursion on that parameter)
- Literals are joined by a simple lattice of Bottom/Precise/Top where joining two precise literals results in the literal if they are the same, otherwise top.
- Unknown externals etc are represented just by the resulting type
- Errors are just errors that accumulate -- TODO: consider what to do here