Abstract compilation: A new implementation paradigm ... - Springer Link

5 downloads 141649 Views 703KB Size Report
illustrate this paradigm by its application to the problem of control flow .... 0cfa-program. 0cfa-call. 0cfa-app. 0cfa-abstract-app. 0cfa-args. 0cfa-prim lookup. A. A.
Abstract Compilation: A New Implementation Paradigm for Static Analysis Dominique Boucher and Marc Feeley D4partement d'informatique et de recherche op4rationnelle (IRO) Universit4 de Montr4al C.P. 6128, succ. centre-ville, Montr4al, Qu4bec, Canada H3C 3J7 E-ma~l: {boucherd, f eeley}@iro .umontreal. ca

A b s t r a c t . For large programs, static analysis can be one of the most time-consuming l~hases of the whole compilation process. We propose a new paradigm for the implementation of static analyses that is inspired by partial evaluation techniques. Our paradigm does not reduce the complexity of these analyses, but it allows an efficient implementation. We illustrate this paradigm by its application to the problem of control flow analysis of functional programs. We show that the analysis can be sped up by a factor of 2 over the usual abstract interpretation method.

Keywords: Abstract interpretation, static analysis, partial evaluation, compilation, control flow analysis. 1

Introduction

As the trend in designing higher level languages continues, it is increasingly becoming important to design compilation techniques to implement them efficiently. Optimizing compilers for such languages must typically perform a variety of static analyses to apply their optimizations. Most of these analyses are very time-consuming. It is therefore essential to perform them as efficiently as possible. Speed of analysis is the issue addressed in this paper. For the class of first-order imperative languages, several techniques for static analysis have been designed and are now well established [1, 6]. Static analysis of higher-order functional languages is more difficult because the control flow graph (call graph) is not known at compile-time. Nevertheless, several kinds of analyses have been designed [2, 7, 15] and some have been successfully integrated in real compilers [13, 15]. 1.1

A New

Paradigm

A popular approach for implementing static analyses is non-standard interpretation. Even traditional data-flow analysis can be viewed as an interpretation layer, the flow graph being the abstract program to be "executed". The more recent analyses, devised in the abstract interpretation framework, are implemented as true interpreters (for example, see [13]).

193

But interpretation is costly because it adds a layer of abstraction to the analysis process. We propose to go one step further and perform what we call abstract compilation. This new paradigm is based on a simple idea: instead of interpreting (in some sense) the source program, we compile it into a program which computes the desired analysis when it is executed. More formally, suppose we want to compute some static analysis S. S can be viewed as a function of two arguments. The first is the program p we want to analyze. The second is an initial abstract environment c~0 that depends on the analysis to be performed. The result of the analysis, S(p, ~0), is an abstract environment which contains the desired information. The abstract compilation of p, C(p), would then be a function of one argument such that: = S(p,

Essentially, C is nothing more than a curried version of S. But, as we will see, only the "real computational part" of S(p, ~r0) can be kept in the code of C(p). For instance, there is no traversal of the abstract syntax tree of p in C(p). This way, all the overhead of interpretation is eliminated. The abstract compilation process is really a kind of ad hoc partial evaluation. In fact, the abstract compiler C can be seen as a partial evaluator specialized for the static analysis S. Devising C directly made us aware of several interesting optimizations that can be performed to further speed up the analysis. Our results show that, using the technique of abstract compilation, the analysis can be sped up by over a factor of 2. 1.2

Overview

In this paper, we demonstrate our paradigm by showing how the control flow analysis (cfa) of higher-order functional programs can be compiled. We first describe the analysis and the language we want to analyze. Then, two different compilation strategies are presented. The first compiles the analysis into a textual program which is then executed using a general interpretation procedure. The second shows how we can use closures to produce a more efficient analysis program. Finally, we compare our results with more conventional implementations of the cfa. Throughout the text, the Scheme programming language is used, mainly for our examples. But note that the compilation algorithms presented here do not rely on any particular language although they use some Lisp-like notation. 2

Control

Flow

Analysis

In higher-order functional languages, functions are first-class objects, i.e. they can be passed as arguments to other functions, returned as the result of functions, stored in data structures, and so on. It is thus more difficult to predict at compile-time the behavior of programs making heavy use of higher-order functions. One way to do so is the control flow analysis (cfa), of which there exists

194

several variants [2, 8, 12]. The Ocfa [15] computes, for each call site ( a . . . ) of a program, the set of functions that could be bound to a at runtime. To appreciate the usefulness of Ocfa, consider the Scheme program of Fig. 1. The Ocfa would find that in the map function, the only function that can be bound to f results from the evaluation of (lambda (y) (+ y x ) ) (the result of applying a d d e r to the value 1 or 2). Knowing this, the compiler can optimize the runtime representation of the closure and the call to f. Rather than being a record with a code pointer and environment, the "closure" could simply be the value of x (1 or 2) and the call to f can be replaced by a j u m p to the body of the lambda expression with x as an argument.

(define (adder x) (lambda (y) (+ y x)) (define (map f i) (if (null? i) '() (cons (f (car i)) (map f (cdr i))))) ( l e t ( ( l s t '(1 2 3 4 5 6))) (append (map (adder 1) l s t ) (map (adder 2) l s t ) ) )

Fig. 1. A small program // C L F A V K P

6 Prog 6 Call E Lam 6 Fun EArg 6 Var 6 Const E Prim (primitive functions: if, +, etc.)

~/::~- C C ::= (F A1 ... A,) [ ( l e t r e c ((FI L1) .-- (Fn Ln)) C) L ::= (A (V1 ... V.) C)

F::=LIV]P A::=K1VIL Fig. 2. Abstract syntax

We will now see how we can compute this cfa. Figure 2 describes the abstract syntax of our source language. It is a continuation passing style (CPS) A-language. We assume that all programs are fully alpha-converted. We use CPS

195

to simplify the analysis. Special forms like i f can then be considered as primitive functions and all intermediate results are given names. Since only lambdaexpressions, variables, and primitives can a p p e a r in the o p e r a t o r position of a call site, the Ocfa p r o b l e m is equivalent to the one of finding, for each variable v occurring in the p r o g r a m , the set of functions t h a t can be b o u n d to v. O u r use of C P S carries no loss of generality since any n o n - C P S p r o g r a m can be easily converted to an equivalent C P S p r o g r a m . Figure 3 gives the functionalities of the a b s t r a c t interpretation a l g o r i t h m 1 for Ocfa shown in Fig. 4. T h e following t e r m i n o l o g y is assumed. First, l -~formalsi stands for the ith formal p a r a m e t e r of procedure l. Similarly, l Jrbody is the b o d y of procedure l (a call site). T h e abstract e n v i r o n m e n t s are functions f r o m syntactic d o m a i n Vat and deliver results in d o m a i n 2 Lain. T h e e m p t y e n v i r o n m e n t is denoted (r0 ((r0(v) = 0 for all v) and [v ~ S] s t a n d s for the e n v i r o n m e n t (r such t h a t (r(x) is S if x = v and ~ otherwise. Finally, environments can be joined using the II operator, defined by ((r U (r')(v) = or(v) U ~'(v).

A

A

0cfa-program : Prog x Env -~ Env h

0cfa-call : Call x Env -~ Env 0cfa-app : Fun x Arg* x Env -~ Env 0cfa-abstract-app : 2 Lain x Arg* x ~nv --~ ~nv h

0cfa-args : Arg* • Env -+ Env h

0cfa-prim : Prim x Arg* x Env -~ Env A

lookup : Arg x Env -+ Env A

Env = Var -+ 2 Lam

Fig. 3. Functionalities

T h e Ocfa of a p r o g r a m p is c o m p u t e d by finding an e n v i r o n m e n t cr such t h a t c~ = Ocfa-program(p, cr). This can be done iteratively by successive a p p r o x i m a tion, starting with c~0. It can easily be shown t h a t this process eventually terminates. T h e a p p r o x i m a t i o n s (r0, t r l , . . , f o r m an ascending chain (taking (r U ~rI to m e a n (r(v) C ~r'(v) for all v), since we only add elements to the environment. Also, since every p r o g r a m is finite, ~(v) m u s t be finite for all v. T h u s our algorithm will find (r in a finite n u m b e r of steps. This is the usual way the Ocfa is implemented. For example, [13] describes the analysis performed in the Bigloo compiler [14]. It is essentially the s a m e as the one we have presented. It is also very close to the one presented by Shivers 1 For the sake of simplicity, we do not include any error-detection mechanism to the Ocfa. We thus assume that all programs are syntactically valid.

196

Ocfa-program(p, ~)

Ocfa-call(p, ~r)

=

Ocfa-caU(f(f a l . . . a~)], o') = Ocfa-app(f, ( a , , . . . , an), Ocf~-args((a,,..., a , ) , ~)) Ocfa-call(~(letrec ( ( f , t~) ... ( I - l~)) c ) ] , a ) = 0era-call(c,. u [I, ~ {t,}] u - . u [f- ~ {l.}]) Ocfa-app(f, ( a l , . . . , an), a) = cond

isVar(f): 0 c f a - a b s t r a c t - a p p ( a ( f ) , ( a l , . . . , an), a) isPrim(f) : Ocfa-prim(f, (al . . . . . an), o') isLam(f) : let a' = a U [f +formals,~-+ lookup(al, a)] U . . . 9 .. 12 [f +formals. ~'+ lookup(an, er)] in Ocfa-call(f +body, a')

0cfa-abstract-app(0, (ai . . . . , a , ) , a) = a 0cfa-abstract-app(S, (al . . . . . an), a) = let l = some m e m b e r of S a ' = a t2 [l +formalsi ~+ lookup(a1, a)] U . . . 9 .. U [i +formals J-+ l o o k u p ( a , , a)] in 0cfa-abstract-app(S - {l), ( a l , . . . , an), a') Ocfa-args(O, a) ----a Ocfa-~rg~((a,,..., a , ) , ~) = let a ' = if isLam(al) t h e n a U 0cfa-call(al else a in Ocfa-args((a2,..., a , ) , ,d)

+body, 0")

Ocfa-prim({+], (al . . . . , a3) , o') = Ocfa-args((al . . . . , a3), o') Ocfa-prim([if], ( a ~ , . . . , as), a) = Ocfa-args((aa,..., a3), a)

lookup(e, a) = eond isConst(e) : 0 isya~(~) :

~(~)

Fig. 4 . 0 c f a abstract interpretation algorithm

197

in [15]. We will now show how we can compile the analysis, by extending the interpretation algorithm.

3

A First A b s t r a c t C o m p i l e r

When many iterations are needed for the algorithm to reach a fixed point, a lot of work is done which does not have a direct impact on the result of the analysis. The reason for this is that each iteration requires a traversal of the entire syntax tree, examining each node to see if it is an application, an abstraction, etc. This is the interpretation overhead. When we consider the interpretation algorithm of Fig. 4, we notice that only three functions can actually influence the result of the analysis: Ocfa-call when applied to a letrec special form, Ocfa-app when f is a A-expression, and Ocf~-abstract-app. What we are interested in is a way to remember only those computations which affect the final result of the analysis. Consider the sample CPS program of Fig. 5, where each A-expression has been numbered from 1 to 7 (we will later refer to these expressions as AI to AT). It defines a currified version of apply, a function such that ((apply f ) x) = (f x). The program then computes ( ( a p p l y ( a p p l y (A (x) (+ x 1 ) ) ) ) 2).

((A~ (apply kl) (apply (~2 (xl k2) (+ xl 1 k2)) (~4 (t2) (apply t2 (As (t3) (t3 2 k l ) ) ) ) ) ) (R6 (f k3) (k3 (R7 (x2 k4) (f x2 k 4 ) ) ) ) tl-cont)

Fig. 5. A small CPS program.

By carefully examining the program, we can determine the particular call sites where the control flow information will be propagated. The call (A1 A6 t l - c o n t ) will add A6 to c~(apply) and t l - c o n t to cr(kl). This is the simplest case. But consider an inner call site, ( t 3 2 k l ) , in A5. The analysis will take each A~ E cr(t3) and will add a ( k l ) to er(Ai Jrformals2). In constrast, the call (+ x l 1 k2) adds no information and has no impact on the final result. Note that only the call sites where the information is propagated are useful for the computation of the analysis. One way to implement the analysis would

198

be to first traverse the syntax tree and store the useful call sites in some d a t a structure and then traverse it at each iteration. But again, there still remains an interpretation layer, namely the computations needed to traverse the d a t a structure. Compilation can overcome this layer of interpretation by replacing the d a t a structure representing the program to analyse by the control structure of another program (the "analysis program"). The only "interpretation" that remains is at the processor level but since this is unavoidable we will not count it. Figure 6 shows a first compilation algorithm for Ocfa. We use a Scheme-like notation for the produced code. The function comp-program takes as input a program p and produces p', the analysis program 2 in source form. When p' is run, it performs the analysis by finding an abstract environment such that c~ = pt(c~), by the technique of successive approximation. To see how it works, consider the following program: ((~1 ( f c l ) ( ( ~ (x c2) ( f x c2)) 2 cl)) (~3 (y c3) (+ y 1 c3)) tl-cont)

Once compiled, we get the following analysis program:

(1) (2) (3)

(~ (~) ((~ (~) ((~ (~) ((~ (~) ((~ (a) ((A (a) ( 0 c f a - a b s t r a c t - a p p a(f) a x c2)) a U [ x ~ (lookup 2 c)]U[c2~-+ (lookup cl a)])) ((~ (~) ~) ~))) a U [f ~ (lookup ~3 ~)] U [cl ~-4 (lookup t l - c o n t a)])) ((~ (a) ~) a))) ((~ (a) ((~ (a) ((~ (a) a) ((~ (a) a) a))) (CA (a) ~) ~))) ~)))

2 We assume that Oc/a.abstract.app and resulting program.

lookup can be "linked" in some way with the

199

comp-program(p) ----comp-call(p) comp-call([(f a l . . . a N ) ] ) = let C1 -- comp-args((al . . . . . an)) C2 = comp-app(f, (al . . . . . an)) in [(A (~) (C2 (C1 a ) ) ) ] comp-call([[(letrec ((Ix 11) ... (In ln)) c ) ~ ) = let C = comp-call(c) in [[(A (a) (C a u [fl ~ {ll}] IA... IA[fn ~-+ {/,~}]))]] comp-app(f, ( a a , . . . , a n ) ) = cond

is Var( f ) : [CA (~) ( 0 c f a - a b s t r a c t - a p p (a f ) a al ... a n ) ) ]

isPrim(f) : Ocfa-prim(f, (al . . . . , an))

isLam(I) : let C = comp-call(I Jebody) in [(A (~) (C ~r U [f .~formals ~--~ (lookup ai r

)]

comp-args(O ) = [(A (a) a)]] comp-args((al,..., an)) = if isLam(a1 ) t h e n let Cl = comp-call(al Sbody) C2 = comp-args((a2, . . . , aN)) in [(A (a) (C2 (CI ~r)))] else comp-args((a2 . . . . , aN)) comp-prim(~+~, ( a l , . . . , a3)) = comp-args((aa . . . . . a3)) comp-prim([if~, ( a l , . . . , a3)) = comp-args((ax . . . . , a3))

Fig. 6.0cfa compilation algorithm

It is not hard to see t h a t only lines (1), (2), and (3) will contribute to the abstract environment. We can also see t h a t there are still a n u m b e r of useless c o m p u tations done by this analysis p r o g r a m . T w o simple o p t i m i z a t i o n s can further reduce the n u m b e r of c o m p u t a t i o n s performed at each iteration. We can first eliminate all the calls to the identity function (A (or) cr) by performing r/-reductions. This can be done at low cost by a d d i n g additional tests to the compilation process. For example, assuming t h a t I d - F u n c t ? is true if its a r g u m e n t is the code of the identity function, the comp-call function becomes:

200 c o m p - c a l l ( [ ( f (il . - - ( i n ) ] ) --l e t C1 : c o m p - a r g s ( ( a l , . . . , a , ) )

(72 = comp-app(/, (al . . . . , an)) in if Id-funct?(C1) t h e n C2 else if Id-funct?(C2) t h e n C1 else [(~ (a) (C2 (C1 a)))]] The second optimization comes from the behavior of lookup. When applied to a constant, it returns the e m p t y set; when applied to a A-expression, it returns the set containing only this expression. This leads to the following optimization. First, we can eliminate all the contributions of the form [v ~ ( l o o k u p c a)], where v is a variable and c is constant. Also, we can remove the environments of the form [v ~-+ {Ak}] and add t h e m to the initial environment. This saves one iteration, but more importantly, it simplifies the lookup mechanism and makes each iteration faster. When these two optimizations are added to the compilation algorithm, the compiled code for the previous example now becomes (), (a) ( O, (a) ((~ (a) ( O, (a) (()~ (er) ( 0 c f a - a b s t r a c t - a p p a(f) a x c2)) at_J[c2~+ (lookup cl ~)])) a)) a t.J [cl ~ (lookup t l - c o n t a)]) ) ) ) Starting with cr~ -- If ~ {A3}] (as computed by the second optimization), we can find that ch = [f ~-+ {A3}] is a fixed point for this function in only one iteration. This solution is not entirely satisfactory. The layer of abstraction is no longer present in the resulting code but the program must be executed in some way, thus requiring interpretation at another level. If, for example, we use a builtin interpretation procedure, like Scheme's e v a l , our experimentations reveal that it remains much more efficient to compute the ahalysis by means of abstract interpretation. But it is possible to do better.

4

Representing the Compiled Analysis with Closures

Many functional p r o g r a m m i n g languages allow the user to create new functions via A-expressions. When these expressions are evaluated, they return a c l o s u r e , i.e. a function that remembers the current environment. We will use closures here to overcome the interpretation overhead of the analysis program. The idea is to represent a compiled expression with a closure. When this closure is applied, it performs the analysis of the given expression.

201

We will thus replace the "code generation" by a "closure generation" (as in the work of Feeley and Lapalme [5]). This leads to the compilation algorithm of Fig. 7 (without the optimizations discussed above). comp-program(p) ----comp-caJl(p) comp-call([[(f a i . . . a n ) ] ) = let C, = comp-args((a,,...,a=)) (72 = comp-app(f, (ax . . . . . an)) in Aa.C2(Ca(a)) comp-call([(letrec ((I1 11) ... (In In)) c ) ] ) = let C = comp-call(c) in Ao-.C(aU[f, ~ { l a } ] U ... U [ f n ~ {1,,,}]) comp-app(I, ( a l , . . . , an))- = eond is Var( I ) : Aa.Ocfa-abstract-app(a(I), a, (aa,..., an)) isPrim(f) : Ocfa-prim(I, (al . . . . . an)) isLam(f) : let C = comp-call(I "['body) in Aa.C(a H [f Jcformals,~'~ lookup(a/, a)]) comw~gs(())

= ~.~

comp-args((a,,..., an)) = if isLam(a, ) t h e n let C1 = comp-call(al ~'body) 62 = comp-args((a2 .... ,an)) in Acr.C2(Ca (a) ) else comp-args((a2,..., an>) comp-prim([[+],