Introduction to Program Synthesis

© Armando Solar-Lezama. 2018. All rights reserved.

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 systemstreambit, 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 ChowdhuryItzhaky0SYLLC16. 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 paperKneussKKS13. 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.