Lecture 18: Deductive/Combinatorial Hybrid Schemes
Lecture18:Slide2
Deductive techniques are a seemingly major departure from the kinds of
synthesis techniques we have seen up to this point.
Whereas most of the techniques we have seen so far are based on search
over a space of programs for one that can be verified as correct,
in deductive techniques, the initial specification is transformed in
a semantics preserving way to arrive at the desired specification.
Roughly speaking, the combinatorial search-based techniques
can be seen as assembling a shape from a block of Legos, where
the goal is to assemble the given building blocks into a desired
configuration, and the main complexity comes from the number of
possible Lego pieces and the ways they can be assembled together.
By contrast, a Rubix Cube requires you to apply a set of predefined
transformations to reach a desired configuration. Part of what
makes the Rubix Cube challenging is that even if you know
the high-level change that you want to achieve, you need to be able
to decompose it into the correct sequence of transformations in order
to achieve it.
Both approaches to synhesis are quite different, and over the years,
there have been a number of efforts to combine them in order to achieve
the best of both worlds. In this lecture, we explore a few different
strategies for combining these two forms of synthesis.
Search-enabled transformations
This approach addresses two problems with deductive synthesis.
The first problem is that there is often a mismatch between the level
of abstraction of the derivation rules and the level of abstraction
at which a user thinks of how to derive a solution. So, what to the
user looks like one derivation step, is in reality a series of
low-level transformations, so the user needs to figure out how
to compose these together to perform the high-level transformation.
For example, in the case of the Fibbonacci example from the
previous lecture, what the user may think of as one step of
transforming $g(x+1)$ so it is defined in terms of $g(x)$,
requires in fact a series of transformations to achieve.
The second problem is that sometimes the rules are parameterized by
functions or predicates that must satisfy some side conditions, so
it is difficult for the user to know which function to provide and
then prove the required side conditions. A good example of these is the
conditional formation rule from the previous section, where the user
needs to provide the correct parameter $P$ to form the condition.
Lecture18:Slide5;
Lecture18:Slide6;
Lecture18:Slide7;
Lecture18:Slide8;
Lecture18:Slide9;
Lecture18:Slide10;
Lecture18:Slide11
The high-level idea of this approach is to define high-level transformations
that more closely match the intuition of the user, and to perform
search inside the transformation to find the low-level details of
how the code needs to be transformed and how to prove the transformation
correct.
An early exemplar of this style of work is our own StreamBit system
streambit,
which applied this idea to the problem of synthesizing efficient implementations
of bit-level stream manipulations. The figure illustrate a simple such transformation.
In this case, the goal is to drop every third bit from a stream of bits. While the transformation
is straightforward, the implementation must manipulate the stream at the level of words and
ought to take advantage of word-level parallelism.
The figure illustrates the process of going from a specification in terms of individual bits
to one that operates at the word level. It also shows two different implementations of the
first word-level transformation, one that requires $N/3$ operations for a size $N$ word,
and another one that takes $O(log(N))$ operations by closing half the gaps at every step.
So the goal for this work was to allow developers to explore the space of possible implementations
and try out ideas like the logarithmic implementation without the risk of introducing bugs.
Lecture18:Slide14;
Lecture18:Slide15;
Lecture18:Slide16;
Lecture18:Slide17;
Lecture18:Slide18;
Lecture18:Slide19;
Lecture18:Slide20;
Lecture18:Slide21;
Lecture18:Slide22
The high-level idea for the approach is described in the figure.
The first element of the approach is a data-flow language that allows
users to describe their bit-level implementations. For example,
for the drop-third example, the implementation was described as a filter
that would be repeatedly invoked on a stream and at each step would pop
three bits from the input stream and push the first two to an output
stream. A critical element in this language is that the same notation
that a programmer could use to describe high-level specifications
could be used to describe low-level implementations. Thus, the
process of generating an implementation could be though of
as a transformation process within a uniform notation as illustrated
in the figure. Basically, for every program there is a space of equivalent
programs, some subset of which are low-level enough that they can be mapped
directly to assembly instructions that can execute in the machine.
The second element of the approach is a baseline
Automatic Transformation
that can transform any program to a low-level program that corresponds to an implementation.
So for any high-level program that I write, I can treat the automatic transformation
as a compiler that will completely automatically generate an implementation for me.
This automatic transformation consists of a series of simple and predictable heuristics
for applying simple transformation rules that are guaranteed to lead to an implementation.
In some cases, this default implementation will be good enough, but it will rarely be optimal.
For instance, for the drop-third example, this default transformation rules lead to the
inefficient $O(n)$ implementation, as opposed to the efficient $O(log(N))$ one.
In order to get a different transformation, the user can override some step of the automatic
transformation to guide the system to a different path. This can be dangerous, however.
If the user simply changes the implementation manually, there is a risk of introducing bugs.
The defining idea in StreamBit was instead to allow the user to Sketch a transformation,
and then rely on a search procedure to find a complete transformation that matched
the Sketch and could be proven correct.
Lecture18:Slide25;
Lecture18:Slide26;
Lecture18:Slide27
The figure shows what some of these sketched transformations looked like.
In the case of dropThird, the sketch states that in each step, some bits
must shift by zero and some by one; in the second step, some bits shift
by zero and some by two, and so forth. The system needs to search over
the space of all transformations that match the sketch to find the one
that preserves the semantics of the original filter.
These sketched transformations were where the original ideas for the Sketch
language first arose. The idea of the sketch as a partial program, and
the use of a combinatorial search procedure backed by a verification
algorithm to find the implementation that is provably equivalent to some
reference implementation. Both the search and the verification procedure
used by this paper, however, were extremely ad hoc and really only suitable
for a very small set of bit-stream manipulations. It was the dissatisfaction
with this lack of generality that eventually pushed us to explore
more general search procedures based on SAT solving in what eventually
became the modern Sketch language that we discussed in
Lecture 7.
In moving from StreamBit to Sketch, however, we lost the very powerful deductive
elements of the StreamBit system.
Revisiting search-enabled transformations
Lecture18:Slide30;
Lecture18:Slide31;
Lecture18:Slide32;
Lecture18:Slide33;
Lecture18:Slide34;
Lecture18:Slide35
My group actually revisited the idea of search-enabled transformations
in a paper led by Shachar Itzhaki in collaboration with
Rohit Singh, myself, Kuat Yessenov, Yongquan Lu, Charles Leiserson and Rezaul Chowdhury
Itzhaky0SYLLC16.
That paper focused on the domain of efficient divide-and-conquer implementations
of dynamic programming algorithms. The motivation for the work was an observation
made years earlier by Rezaul Chowdhury and his collaborators that
traditional dynamic programming implementations were very cache inefficient
and could be made more efficient through what are often called
cache oblivious implementations.
ChowdhuryR06. The basic idea is illustrated in the figure; the cache oblivous
implementation is a divide-and-conquer implementation that recursively computes the
solution for each quadrant.
These recursive implementations can be very efficient but are also very difficult
to derive. As a solution, the paper proposed a system called Bellmania that
used search-enabled transformations to deductively derive these implementations
from a very high-level specification.
The system followed the same basic approach as StreamBit, but it leveraged much more general
search and verification machinery in order to tackle this much more complex domain.
Deductive first approaches
Another approach to combining deductive and combinatorial synthesis is through
a deductive-first approach, where deductive rules are first used to
break a challenging synthesis problem into simpler more tractable problems, and once
the deductive rules can no longer simplify the problem, a combinatorial search approach is
used to solve the local synthesis problems.
The canonical example for such a system is the Leon synthesizer by Kneuss, Kuraj, Suter and Kuncak
KneussKKS13.
Lecture18:Slide46;
Lecture18:Slide47;
Lecture18:Slide48
The figure shows the basic formalism behind the Leon system. In this formalism,
a synthesis problem has two key elements, a path condition $\Pi$, and a specification
predicate $\phi$. The specification relates a set of input variables $\bar{a}$ to a set
of output variables $\bar{x}$. The path condition provides
a set of conditions on the input variables $\bar{x}$ that
can be assumed during the synthesis process.
The result of synthesis is a program $\bar{T}$, and a precondition
$P$, such that for any values of the input variables that satisfy
the precondition $P$ and the path condition $\Pi$, the result
of executing the program on those inputs will be a set of outputs
that satisfy the specification $\phi$.
The figure shows a couple of the deductive rules used by Leon,
all of which come directly from the paper
KneussKKS13.
For example, the one-point rule deals with the case where
the specification fully prescribes the value of one of the
output variables $x_0$ in terms of other output variables or input
variables. In that case, the rule says one can solve a simpler
synthesis problem involving one fewer output variable, and then
one can compute $x_0$ from the resulting input and output variables.
Lecture18:Slide49;
Lecture18:Slide50;
Lecture18:Slide51;
Lecture18:Slide52;
Lecture18:Slide53;
Lecture18:Slide54;
Lecture18:Slide55;
Lecture18:Slide56;
Lecture18:Slide57
The case-split rule is similar to what we did before both
in the context of STUN and Synquid: given a complex condition,
find a program $T_1$ that works for a subset of the cases
covered by that condition, and then solve for a
program $T_2$ that works for the rest of the cases.
The List recursion rule is quite a bit more complex.
The rule describes how to decompose a specification involving
a recursive datatype into a specification for a base case
and a specification for a recursive case.
The figure shows an example of how this rule operates.
The rule reduces the problem to two simple synthesis problems.
From those simpler problems, it is easy to see that
$T_1$ corresponds to
out=Nil
thanks to the one-point rule,
and $T_2$ corresponds to
out= Cons(2*h, r)
, again thanks to the
one-point rule. This means that the overall code will look like this.
def rec(a0){
require(true)
a0 match{
case Nil => out=Nil
case Cons(h,t) =>
lazy val r = rec(t)
out = Cons(2*h, r)
}
}
It is possible to define analogous rules to this one for
arbitrary recursive data types.
In general, it will not always be the case that the deduction
rules alone will give us a solution, like they did in this case.
Instead, the idea is that these rules can be used to
simplify the problem or break it into smaller problems.
Once the rules no longer apply, one can rely on some of the
combinatorial search techniques we have been studying throughout the
course to find a correct implementation.