Generating specialized C++ Code from ... - Semantic Scholar

6 downloads 0 Views 36KB Size Report
These code substitution rules are combined recursively. For instance, the ..... It is now easy to see how we can use differentiation to compile CLAIRE rules into.
Generating specialized C++ Code from parametrized CLAIRE pseudo-code Yves Caseau, François Laburthe LIENS, École Normale Supérieure 45 rue d’Ulm, 75005 Paris, France [email protected], [email protected]

1. Introduction C L A I R E is a programming language which provides high levels of abstraction and parametrization which may be used as an executable pseudo-code to describe concise and reusable problem solving algorithms. We have shown in [CL96a] how CLAIRE uses few lines to express complex operations. We here here explain how these concise descriptions can be transformed into semantically equivalent but much faster (and longer) C++ code. This enhanced compiling process allows to combine performance and expressiveness. We describe several such techniques:

• First, we present the compilation of set iteration and functional composition. • Then, we show how the user may customize the compiler through explicit second-order types and reduction rules that exploit composition polymorphism (the ability to perform a different operation based on how the argument was obtained as opposed to what the argument is). This is used to implement elegantly simplification rules that are common with complex data structures • Last, we give a brief overview of the techniques used to compile rules into procedural demons.

2. Compiling and Code Substitution We here describe how we deal with some of the features of CLAIRE (that are presented in [CL96a]). It is important to notice that our goal is to generate very efficient code, at the possible expense of size or modularity. That means that we do not consider it a problem that a change in the implementation of a set data structure might cause a further re-compilation of a client module. It is our belief/experience that to be made efficient, a program that uses sets must actually be optimized according to the exact implementation strategy for the sets. Our goal is simply to have this done automatically. In that sense, this work is orthogonal with the work on theta [DGLM95] which operates on the opposite assumption. 2.1 Iteration It is easy to provide with different type of sets in a programming language if we allow for an implicit coercion into a canonical representation when the set must be iterated. Unfortunately, this is not acceptable from a performance viewpoint: it is essential that each type of set expression is iterated without the explicit construction of the set that it represents. • for x in c:class e must generate code that traverse the class hierarchy and iterates over the instance lists of all descendent of c. • for x in (a .. b) e must generate a usual incremental loop if a,b are integers or chars. • for x in {y in S | P(y)} e must fold the evaluation of the test P(x) into the iteration of the set S. • for x in {f(y) | y in S} e must perform a substitution of x by f(y) in the code obtained by the iteration of y over S.

-2These code substitution rules are combined recursively. For instance, the following iteration for x in {f(y) | y in {z in (1 .. 10) | P(z)}} g(x)

is transformed into let z := 1 in while (z ≤ 10) (if P(z) g(f(z)), z :+ 1)

Next, we have found it necessary to make these iteration rules extensible for the new set objects created by the user. This is done through the definition of the operation iterate, as in the following examples : [iterate(s:htable, v:Variable, e:any) => for v in arg(s) (if known?(v) e) ] [iterate(s:Interval, v:Variable, e:any) => for v in users(machine(s)) (if SET(s)[index(v)] e) ] CLAIRE uses these definitions as follows. Whenever it finds a expression for x in S e, it looks if there is a restriction of iterate that matches the type inferred for S. If this is the case, the for instruction is replaced by the body of the method, after a proper substitution of the parameters (=> is the syntactical mark for inline methods, a la C++[ES90], cf. Section 3.2). The first example tells how to iterate a set represented by a htable from Section 2.3: we need to iterate the arg list and discard all unknown values. The second example uses the task intervals that we mentioned previously. A task interval s is bound to a machine machine(s) that keeps a list of tasks that use this machine (users(...)). Each task has a unique index and the set is represented by a bit-vector SET(s). Thus the iteration consists of iterating the set of tasks on the right machine whose indexes belong to the bit-vector. Also, it is important to notice that these reduction rules apply to all iterations, including those who are implied by other set constructions such as {y in S | P(y)} or {f(y) | y in S}. For instance, the two expressions

{Atleast(t) | t in x} {c in (class but class) | length(slots(c)) > 10}

will be transformed respectively into let s := {} in (for t in users(use(x)) (if SET(x)[index(t)] s :add Atleast(t)), s) let s := {} in (for c1 in descendants(class) for c in instances(c1) (if (c != class & length(slots(c)) > 10) s :add c), s)

Notice, however, that no explicit set would be built for an expression y in {Atleast(t) | t in x} e(y)

for

2.2 Functional Programming and Inline Substitution CLAIRE supports inline methods, in a way that is very similar to C++. When the syntactical mark => is used instead of -> , the compiler will substitute the code given as the definition when it seems appropriate. For instance, we may define :

[min(x:any, y:any) => if (x < y) x else y] [sum(s:any) => let x := 0 in (for y in s x :+ y, x) ]

-3-

One could think that it is enough to pass the inline status to the C++ compiler, but this is not the case. Because of features such as set iteration or higher-order functions, the recursive macro-expansion must be performed within CLAIRE, before a simpler code may be passed to C++. This is clearly the case with our second example. The iteration instruction will be transformed into a different expression depending on the type of s. For instance, the following call : sum({x * x | x in (1 .. 10)}

will be transformed into let y := 0, x := 1 in (while (x ≤ 10) (y :+ x * x, x :+ 1), y)

Similarly, there is a close interaction between inline substitution and the use of functional parameter. A constant parameter that represents a property, a method or a lambda will provoke further optimization after its substitution. Consider the following definition, the following call and its extension : [max(s:({} .. integer), greater:property, default:any) : integer => let x := default, empty := true in (for y in s (if empty (x := y, empty := false) else if greater(x,y) x := y), x)] max({x in (1 .. 10) | f(x) > 0}, >=, 0) let x := 0, empty := true, y := 1 in (while (y ≤ 10) (if (f(y) > 0) (if empty (x := y, empty := false) else if (x >= y) x := y), y :+ 1), x)

The function call (x >= y) will be compiled using static binding so that the code generated from the original expression is exactly what would be obtained by writing C++ code directly. The ability to pass function parameters at no cost from a performance point of view is a key feature to write parametric algorithms. This is reinforced by the fact that these parameters can be in-line methods, which means that the original in-line method can be seen as a higher-level function generator. For instance, we can use the previous definition of max to define the following methods on tasks: [ atleast(x) ≤ atleast(y)] [later(x:Interval,t:task) -> max(x but t, eval(args(x)[1])[i,j] + eval(args(x)[2])[i,j] [∈(x:any, s:but[tuple(any,any)]) => (x ∈ eval(args(s)[1]) & x != eval(args(s)[2])) ]

To understand how they work, we first need to say that args(x) is the list of arguments if x is a function call that matches the pattern. The use of eval(...) is an escape to the meta-level which says that the included expression must be evaluated before the substitution is performed (similar to the use of a backquote and a comma in a LISP macro). Suppose that we now have this situation : [trace-1(m:matrix) => sum(list{ 1.0 / M[i,i] | i in (1 .. N)}) ] [f(m1:matrix, m2:matrix) -> trace-1(m1 + m2) > 0.0 ]

The macro-expansion of trace-1(m1 + m2) will yield to an expression that involves (m1 + m2)[i,i] . This call matches the signature of the optimizing restriction that we defined previously (with x = m1 + m2 and args(x) = (m1,m2)). Thus a further reduction is performed and the result is let x := 0, i := 1 in (while (i >= N) ( x :+ 1.0 / (m1[i,i] + m2[i,i]), i :+ 1), x > 0.0)

1

get is used implicitly in the form ...[...] (e.g. M[i,j] = get(M,i,j))

Here the word pattern is used as in “pattern-matching” and not in the other sense of “design patterns” that is more frequent in the OO literature. 2

-5Patterns can also be used to iterate sets that are represented by expressions. Indeed, there are cases where sets are better represented with expressions than with data structures. Let us consider this example which uses but : for c in ({c in class | length(slots(c)) > 5} but class) ....

It would be perfectly possible to implement a difference set with a new data structure, with the appropriate optimization code. However, there are two strong drawbacks to such an approach • it implies an additional object instantiation which is not necessary, • it implies evaluating the component sets to create the instance, which could have been prevented as shown by our first example (the selection set can be iterated without being built explicitly). A better approach is to manipulate expressions that represent sets directly and to express the optimization rules directly. The CLAIRE compiler can be customized by telling explicitly how to iterate a certain set represented by a function call. This is done by defining a new inline restriction of the property iterate with signature (x:p[tuple(A,B,...)],v:Variable,e:any). The principle is that the compiler will replace any occurrence of ( for v in p(a,b,...) e) by the body of the inline method as soon as the type of the expressions a,b,... matches with A,B,.... For instance, we can add the following definition : [iterate(x:but[tuple(any,any)],v:Variable,e:any) => for v in eval(args(x)[1]) (if (v != eval(args(x)[2])) e) ]

The previous examples which were using but have been optimized using this very definition. 2.4 Second-Order Types CLAIRE is not a strongly-typed language. There are many aspects that are not type-safe such as the use of heterogeneous lists and sets. However, CLAIRE uses its rich type system to perform a fair amount of type inference and type checking. This means that most programs that would be easy to write using a statically-typed language can be type-checked by the compiler. Our goal was to support different type disciplines ranging from very loose (for prototyping) up to quite strict. For the later, it was found necessary to extend methods with second-order types. A second-order type is a function, defined by a lambda abstraction, which represents the relationship between the type of the input arguments and the result. More precisely it is a function such that if it is applied to any valid type tuple for the input arguments, its result is a valid type for the original function. Let us consider the two following examples :

[Identity(x:any) : type[x] -> x] [top(s:stack[of = X]) : type[X] -> last(contents(s)) ]

The first classical example states that Identity is its own second-order type. The second one states that the top of a stack always belong to the type of the stack (its of parameter). In the expression that we introduce with the type[e] construct, we can use the types of the input variables directly through the variables themselves, we may also use the extra type variables that were introduced in a parameterized signature, as in the top example. The "second-order type" e may be built using the basic CLAIRE operators on types such as U , ^ and some useful operations such as member. If c is a type, member(c) is the minimal type that contains all possible members of c. For instance, member({c}) = c by definition. This is useful to describe the range of the enumeration method set!. This method returns a set, whose members belong to the input class c by definition. Thus, we know that they must belong to the type member(X) for any type X to whom c belongs (cf. definition of member). This translates into the following CLAIRE definition. [set!(c:class) : type[set[member(c)]] -> instances(c)]

-6From these examples it is easy to realize that the use of second-order types is considered in as a feature for advanced users. It is possible to build a type inference algorithm that will derive many second-order types as an abstract interpretation of the method. However, we have found that the use of these types is limited to a small number of cases. Performing this type inference for each method turns out to be an overkill, and its complexity (cf. [HM91]) does not justify its implementation if it is only used scarcely. However, we may reconsider this decision in the future as we gain more experience with the design of reusable libraries. In the range [BSG95] that goes from untyped and weakly typed language to statically type-checked language, we stand along Eiffel [Mey92] and generate conservative type checks in the compiled code when the compiler fails to type check an expression. It is interesting to notice that we have the same goal of combining parametric and subtyping polymorphism than the theta language [DGLM95], but we use a combination of dynamic typing and code generation to circumvent the problems raised in their paper. However, we believe that CLAIRE could gain a lot by adopting where clauses, which would enable the compiler to generate fewer run-time checks when code generation is not acceptable. The where clauses, or the use of matching [BSG95], support the statement that the type of an argument has a given method with a given signature. Without this statement, we can only check it at run-time (dynamic typing) or check it at compile-time only when there is an inline substitution and the type checker is called after the substitution (as with a C++ template [ES90]). CLAIRE

2.5 Compiling Logic Rules A major contribution from LAURE that we reuse in CLAIRE is the use of a relational algebra to compile propagation rules. In this section we give a brief overview of the algebra and the techniques that are used by the CLAIRE compiler. A more complete presentation may be found in [Ca94] and subsequent references about LAURE. The relational algebra contains terms that represent binary relations. It is generated from a set of constants (cartesian products of types), a set of variables (the slots and arrays of the system) and a set of relational operators. This set may be described as follows (precise definitions are given in the appendix). • ∪, ∩ , o are the usual operators for union, intersection and composition (binary join). • ψ[+](r1, r2) represents the composition of a binary operation (+) with two binary relations r 1 and r2. • φ[