1 | \documentclass[twoside,12pt]{article} |
---|
2 | |
---|
3 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% |
---|
4 | |
---|
5 | % Latex packages used in the document. |
---|
6 | \usepackage{fullpage,times,comment} |
---|
7 | \usepackage{epic,eepic} |
---|
8 | \usepackage{upquote} % switch curled `'" to straight |
---|
9 | \usepackage{calc} |
---|
10 | \usepackage{varioref} % extended references |
---|
11 | \usepackage[labelformat=simple,aboveskip=0pt,farskip=0pt]{subfig} |
---|
12 | \renewcommand{\thesubfigure}{\alph{subfigure})} |
---|
13 | \usepackage[flushmargin]{footmisc} % support label/reference in footnote |
---|
14 | \usepackage{latexsym} % \Box glyph |
---|
15 | \usepackage{mathptmx} % better math font with "times" |
---|
16 | \usepackage[toc]{appendix} % article does not have appendix |
---|
17 | \usepackage[usenames]{color} |
---|
18 | \input{common} % common CFA document macros |
---|
19 | \usepackage[dvips,plainpages=false,pdfpagelabels,pdfpagemode=UseNone,colorlinks=true,linkcolor=blue,citecolor=blue,urlcolor=blue,pagebackref=true,breaklinks=true]{hyperref} |
---|
20 | \usepackage{breakurl} |
---|
21 | \urlstyle{sf} |
---|
22 | |
---|
23 | |
---|
24 | % reduce spacing |
---|
25 | \setlist[itemize]{topsep=5pt,parsep=0pt}% global |
---|
26 | \setlist[enumerate]{topsep=5pt,parsep=0pt}% global |
---|
27 | |
---|
28 | \usepackage[pagewise]{lineno} |
---|
29 | \renewcommand{\linenumberfont}{\scriptsize\sffamily} |
---|
30 | |
---|
31 | % Default underscore is too low and wide. Cannot use lstlisting "literate" as replacing underscore |
---|
32 | % removes it as a variable-name character so keywords in variables are highlighted. MUST APPEAR |
---|
33 | % AFTER HYPERREF. |
---|
34 | \renewcommand{\textunderscore}{\leavevmode\makebox[1.2ex][c]{\rule{1ex}{0.075ex}}} |
---|
35 | \newcommand{\NOTE}{\textbf{NOTE}} |
---|
36 | \newcommand{\TODO}[1]{{\color{Purple}#1}} |
---|
37 | |
---|
38 | \setlength{\topmargin}{-0.45in} % move running title into header |
---|
39 | \setlength{\headsep}{0.25in} |
---|
40 | |
---|
41 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% |
---|
42 | |
---|
43 | \CFAStyle % CFA code-style for all languages |
---|
44 | %\lstset{ |
---|
45 | %language=C++,moredelim=**[is][\color{red}]{@}{@} % make C++ the default language |
---|
46 | %}% lstset |
---|
47 | %\lstnewenvironment{C++}[1][] % use C++ style |
---|
48 | %{\lstset{language=C++,moredelim=**[is][\color{red}]{@}{@}}\lstset{#1}}{} |
---|
49 | |
---|
50 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% |
---|
51 | |
---|
52 | \setcounter{secnumdepth}{3} % number subsubsections |
---|
53 | \setcounter{tocdepth}{3} % subsubsections in table of contents |
---|
54 | \makeindex |
---|
55 | |
---|
56 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% |
---|
57 | |
---|
58 | \title{\LARGE |
---|
59 | Optimization of \CFA Compiler with Case Studies |
---|
60 | }% title |
---|
61 | |
---|
62 | \author{\LARGE |
---|
63 | Fangren Yu -- University of Waterloo |
---|
64 | }% author |
---|
65 | |
---|
66 | \date{ |
---|
67 | \today |
---|
68 | }% date |
---|
69 | |
---|
70 | %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% |
---|
71 | |
---|
72 | \begin{document} |
---|
73 | \pagestyle{headings} |
---|
74 | % changed after setting pagestyle |
---|
75 | \renewcommand{\sectionmark}[1]{\markboth{\thesection\quad #1}{\thesection\quad #1}} |
---|
76 | \renewcommand{\subsectionmark}[1]{\markboth{\thesubsection\quad #1}{\thesubsection\quad #1}} |
---|
77 | \pagenumbering{roman} |
---|
78 | %\linenumbers % comment out to turn off line numbering |
---|
79 | |
---|
80 | \maketitle |
---|
81 | \pdfbookmark[1]{Contents}{section} |
---|
82 | |
---|
83 | \thispagestyle{plain} |
---|
84 | \pagenumbering{arabic} |
---|
85 | |
---|
86 | \begin{abstract} |
---|
87 | \CFA is an evolutionary, non-object-oriented extension of the C programming language, featuring a parametric type-system, and is currently under active development. The reference compiler for the \CFA language, @cfa-cc@, has some of its major components dated back to the early 2000s, which are based on inefficient data structures and algorithms. This report introduces improvements targeting the expression resolution algorithm, suggested by a recent prototype experiment on a simplified model, which are implemented in @cfa-cc@ to support the full \CFA language. These optimizations speed up the compiler by a factor of 20 across the existing \CFA codebase, bringing the compilation time of a mid-sized \CFA source file down to the 10-second level. A few problem cases derived from realistic code examples are analyzed in detail, with proposed solutions. This work is a critical step in the \CFA project development to achieve its eventual goal of being used alongside C for large software systems. |
---|
88 | \end{abstract} |
---|
89 | |
---|
90 | \clearpage |
---|
91 | \section*{Acknowledgements} |
---|
92 | \begin{sloppypar} |
---|
93 | I would like to thank everyone in the \CFA team for their contribution towards this project. Programming language design and development is a tough subject and requires a lot of teamwork. Without the collaborative efforts from the team, this project could not have been a success. Specifically, I would like to thank Andrew Beach for introducing me to the \CFA codebase, Thierry Delisle for maintaining the test and build automation framework, Michael Brooks for providing example programs of various experimental language and type system features, and most importantly, Professor Martin Karsten for recommending me to the \CFA team, and my supervisor, Professor Peter Buhr for encouraging me to explore deeply into intricate compiler algorithms. Finally, I gratefully acknowledge the help from Aaron Moss, former graduate from the team and the author of the precedent thesis work, to participate in the \CFA team's virtual conferences and email correspondence, and provide many critical arguments and suggestions. 2020 had been an unusually challenging year for everyone and we managed to keep a steady pace. |
---|
94 | \end{sloppypar} |
---|
95 | |
---|
96 | \clearpage |
---|
97 | \tableofcontents |
---|
98 | |
---|
99 | \clearpage |
---|
100 | \section{Introduction} |
---|
101 | |
---|
102 | \CFA language, developed by the Programming Language Group at the University of Waterloo, has a long history, with the initial language design in 1992 by Glen Ditchfield~\cite{Ditchfield92} and the first proof-of-concept compiler built in 2003 by Richard Bilson~\cite{Bilson03}. Many new features have been added to the language over time, but the core of \CFA's type-system --- parametric functions introduced by the @forall@ clause (hence the name of the language) providing parametric overloading --- remains mostly unchanged. |
---|
103 | |
---|
104 | The current \CFA reference compiler, @cfa-cc@, is designed using the visitor pattern~\cite{vistorpattern} over an abstract syntax tree (AST), where multiple passes over the AST modify it for subsequent passes. @cfa-cc@ still includes many parts taken directly from the original Bilson implementation, which served as the starting point for this enhancement work to the type system. Unfortunately, the prior implementation did not provide the efficiency required for the language to be practical: a \CFA source file of approximately 1000 lines of code can take multiple minutes to compile. The cause of the problem is that the old compiler used inefficient data structures and algorithms for expression resolution, which involved significant copying and redundant work. |
---|
105 | |
---|
106 | This report presents a series of optimizations to the performance-critical parts of the resolver, with a major rework of the compiler data-structures using a functional-programming approach to reduce memory complexity. The improvements were suggested by running the compiler builds with a performance profiler against the \CFA standard-library source-code and a test suite to find the most underperforming components in the compiler algorithm. |
---|
107 | |
---|
108 | The \CFA team endorses a pragmatic philosophy that focuses on practical implications of language design and implementation rather than theoretical limits. In particular, the compiler is designed to be expressive with respect to code reuse while maintaining type safety, but compromise theoretical soundness in extreme corner cases. However, when these corner cases do appear in actual usage, they need to be thoroughly investigated. A case-by-case analysis is presented for several of these corner cases, some of which point to certain weaknesses in the language design with solutions proposed based on experimental results. |
---|
109 | |
---|
110 | \section{AST restructuring} |
---|
111 | |
---|
112 | \subsection{Memory model with sharing} |
---|
113 | |
---|
114 | A major rework of the AST data-structure in the compiler was completed as the first step of the project. The majority of this work is documented in my prior report documenting the compiler reference-manual~\cite{cfa-cc}. To summarize: |
---|
115 | \begin{itemize} |
---|
116 | \item |
---|
117 | AST nodes (and therefore subtrees) can be shared without copying. |
---|
118 | \item |
---|
119 | Modifications are performed using functional-programming principles, making copies for local changes without affecting the original data shared by other owners. In-place mutations are permitted as a special case when there is no sharing. The logic is implemented by reference counting. |
---|
120 | \item |
---|
121 | Memory allocation and freeing are performed automatically using smart pointers~\cite{smartpointers}. |
---|
122 | \end{itemize} |
---|
123 | |
---|
124 | The resolver algorithm, designed for overload resolution, allows a significant amount of code reused, and hence copying, for the intermediate representations, especially in the following two places: |
---|
125 | \begin{itemize} |
---|
126 | \item |
---|
127 | Function overload candidates are computed by combining the argument candidates bottom-up, with many being a common term. For example, if $n$ overloads of a function @f@ all take an integer for the first parameter but different types for the second, \eg @f( int, int )@, @f( int, double )@, etc., the first term is copied $n$ times for each of the generated candidate expressions. This copying is particularly bad for deep expression trees. |
---|
128 | \item |
---|
129 | In the unification algorithm and candidate elimination step, actual types are obtained by substituting the type parameters by their bindings. Let $n$ be the complexity (\ie number of nodes in representation) of the original type, $m$ be the complexity of the bound type for parameters, and $k$ be the number of occurrences of type parameters in the original type. If every substitution needs to be deep-copied, these copy step takes $O(n+mk)$ time and memory, while using shared nodes it is reduced to $O(n)$ time and $O(k)$ memory. |
---|
130 | \end{itemize} |
---|
131 | One of the worst examples for the old compiler is a long chain of I/O operations: |
---|
132 | \begin{cfa} |
---|
133 | sout | 1 | 2 | 3 | 4 | ...; // print integer constants |
---|
134 | \end{cfa} |
---|
135 | The pipe operator is overloaded by the \CFA I/O library for every primitive type in the C language, as well as I/O manipulators defined by the library. In total, there are around 50 overloads for the output stream operation. On resolving the $n$-th pipe operator in the sequence, the first term, which is the result of sub-expression containing $n-1$ pipe operators, is reused to resolve every overload. Therefore at least $O(n^2)$ copies of expression nodes are made during resolution, not even counting type unification cost; combined with the two large factors from number of overloads of pipe operators, and that the ``output stream type'' in \CFA is a trait with 27 assertions (which adds to complexity of the pipe operator's type) this makes compiling a long output sequence extremely slow. In the new AST representation, only $O(n)$ copies are required and the type of the pipe operator is not copied at all. |
---|
136 | Reduction in space complexity is especially important, as preliminary profiling results on the old compiler build showed over half of the time spent in expression resolution is on memory allocations. |
---|
137 | |
---|
138 | Since the compiler codebase is large and the new memory model mostly benefits expression resolution, some of the old data structures are still kept, and a conversion pass happens before and after the general resolve phase. Rewriting every compiler module will take longer, and whether the new model is correct was unknown when this project started, therefore only the resolver is currently implemented with the new data structure. |
---|
139 | |
---|
140 | |
---|
141 | \subsection{Merged resolver calls} |
---|
142 | |
---|
143 | The pre-resolve phase of compilation, inappropriately called ``validate'' in the compiler source code, has a number of passes that do more than simple syntax and semantic validation; some passes also normalizes the input program. A few of these passes require type information for expressions, and therefore, need to call the resolver before the general resolve phase. There are three notable places where the resolver is invoked: |
---|
144 | \begin{itemize} |
---|
145 | \item |
---|
146 | Generate default constructor, copy constructor and destructor for user-defined @struct@ types. |
---|
147 | \item |
---|
148 | Resolve @with@ statements (the same as in Pascal~\cite{pascal}), which introduces fields of a structure directly into a scope. |
---|
149 | \item |
---|
150 | Resolve @typeof@ expressions (cf. @decltype@ in \CC); note that this step may depend on symbols introduced by @with@ statements. |
---|
151 | \end{itemize} |
---|
152 | |
---|
153 | Since the constructor calls are one of the most expensive to resolve (reason given in~\VRef{s:SpecialFunctionLookup}), this pre-resolve phase was taking a large amount of time even after the resolver was changed to the more efficient new implementation. The problem is that multiple resolutions repeat a significant amount of work. Therefore, to better facilitate the new resolver, every step that requires type information should be integrated as part of the general resolver phase. |
---|
154 | |
---|
155 | A by-product of this work is that reversed dependence between @with@ statement and @typeof@ can now be handled. Previously, the compiler was unable to handle cases such as: |
---|
156 | \begin{cfa} |
---|
157 | struct S { int x; }; |
---|
158 | S foo(); |
---|
159 | typeof( foo() ) s; // type is S |
---|
160 | with (s) { |
---|
161 | x; // refers to s.x |
---|
162 | } |
---|
163 | \end{cfa} |
---|
164 | since the type of @s@ is unresolved when handling @with@ expressions because the @with@ pass follows the @typeof@ pass (interchanging passes only interchanges the problem). Instead, the new (and correct) approach is to evaluate @typeof@ expressions when the declaration is first seen during resolution, and it suffices because of the declaration-before-use rule. |
---|
165 | |
---|
166 | |
---|
167 | \subsection{Special function lookup} |
---|
168 | \label{s:SpecialFunctionLookup} |
---|
169 | |
---|
170 | Reducing the number of function looked ups for overload resolution is an effective way to gain performance when there are many overloads but most of them are trivially wrong. In practice, most functions have few (if any) overloads but there are notable exceptions. Most importantly, constructor @?{}@, destructor @^?{}@, and assignment @?=?@ are generated for every user-defined type (@struct@ and @union@ in C), and in a large source file there can be hundreds of them. Furthermore, many calls are generated for initializing variables, passing arguments and copying values. This fact makes them the most overloaded and most called functions. |
---|
171 | |
---|
172 | In an object-oriented programming language, the object-method types are scoped within a class, so a call such as @obj.f()@ only needs to perform lookup in the method table corresponding to the type of @obj@. \CFA on the other hand, does not have methods, and all types are open, \ie new operations can be defined on them without inheritance; at best a \CFA type can be constrained by a translation unit. However, the ``big 3'' operators have a unique property enforced by the language rules: the first parameter must be a reference to its associated type, which acts as the @this@ parameter in an object-oriented language. Since \CFA does not have class inheritance, the reference type must always match exactly. Therefore, argument-dependent lookup can be implemented for these operators by using a dedicated, fast symbol-table. |
---|
173 | |
---|
174 | The lookup key for the special functions is the mangled type name of the first parameter. To handle generic types, the type parameters are stripped off, and only the base type is matched. Note a constructor (destructor, assignment operator) may not take an arbitrary @this@ argument, \eg @forall( dtype T ) void ?{}( T & )@, thus guaranteeing that if the @this@ type is known, all possible overloads can be found by searching with this given type. In the case where the @this@ argument itself is overloaded, it is resolved first and all possible result types are used for lookup. |
---|
175 | |
---|
176 | Note that for a generated expression, the particular variable for the @this@ argument is fully known, without overloads, so the majority of constructor-call resolutions only need to check for one given object type. Explicit constructor calls and assignment statements sometimes require lookup for multiple types. In the extremely rare case that the @this@-argument type is unbound, all necessary types are guaranteed to be checked, as for the previous lookup without the argument-dependent lookup; fortunately, this complex case almost never happens in practice. An example is found in the library function @new@: |
---|
177 | \begin{cfa} |
---|
178 | forall( dtype T | sized( T ), ttype TT | { void ?{}( T &, TT ); } ) |
---|
179 | T * new( TT p ) { return &(*malloc()){ p }; } |
---|
180 | \end{cfa} |
---|
181 | as @malloc@ may return a pointer to any type, depending on context. |
---|
182 | |
---|
183 | Interestingly, this particular declaration actually causes another complicated issue, making the complex checking of every constructor even worse. \VRef[Section]{s:TtypeResolutionInfiniteRecursion} presents a detailed analysis of this problem. |
---|
184 | |
---|
185 | The ``callable'' operator @?()@ (cf. @operator()@ in \CC) can also be included in this special operator list, as it is usually only on user-defined types, and the restriction that the first argument must be a reference seems reasonable in this case. |
---|
186 | |
---|
187 | |
---|
188 | \subsection{Improvement of function type representation} |
---|
189 | |
---|
190 | Since substituting type parameters with their bound types is one fundamental operation in many parts of resolver algorithm (particularly unification and environment binding), making as few copies of type nodes as possible helps reducing memory complexity. Even with the new memory management model, allocation is still a significant factor of resolver performance. Conceptually, operations on type nodes of the AST should be performed in functional-programming style, treating the data structure as immutable and only copying when necessary. The in-place mutation is a mere optimization that does not change the logic for operations. |
---|
191 | |
---|
192 | However, the model was broken for function types by an inappropriate design. Function types require special treatment due to the existence of assertions that constrain the types it supports. Specifically, it must be possible to distinguish two different kinds of type parameter usage: |
---|
193 | \begin{cfa} |
---|
194 | forall( dtype T ) void foo( T * t ) { |
---|
195 | forall( dtype U ) void bar( @T@ * t, @U@ * u ) { ... } |
---|
196 | } |
---|
197 | \end{cfa} |
---|
198 | Here, only @U@ is a free parameter in the nested declaration of function @bar@, as @T@ must be bound at the call site when resolving @bar@. |
---|
199 | |
---|
200 | Moreover, the resolution algorithm also has to distinguish type bindings of multiple calls to the same function, \eg: |
---|
201 | \begin{cfa} |
---|
202 | forall( dtype T ) int foo( T x ); |
---|
203 | int i = foo( foo( 1.0 ) ); |
---|
204 | \end{cfa} |
---|
205 | The inner call has binding (T: double) while the outer call has binding (T: int). Therefore a unique representation for the free parameters is required in each expression. This type binding was previously done by creating a copy of the parameter declarations inside the function type and fixing references afterwards. However, fixing references is an inherently deep operation that does not work well with the functional-programming style, as it forces eager evaluation on the entire syntax tree representing the function type. |
---|
206 | |
---|
207 | The revised approach generates a unique ID value for each function call expression instance and represents an occurrence of a free-parameter type with a pair of generated ID and original parameter declaration, so references are unique and a shallow copy of the function type is possible. |
---|
208 | |
---|
209 | Note that after the change, all declaration nodes in the syntax-tree representation now map one-to-one with the actual declarations in the program, and therefore are guaranteed to be unique. This property can potentially enable more optimizations, and some related ideas are presented at the end of \VRef{s:SharedSub-ExpressionCaseUniqueExpressions}. |
---|
210 | |
---|
211 | |
---|
212 | \subsection{Improvement of pruning steps} |
---|
213 | |
---|
214 | A minor improvement for candidate elimination is to skip the step on the function overloads and only check the results of function application. As function calls are usually by name (versus pointers to functions), the name resolution rule dictates that every function candidate necessarily has a different type; indirect function calls are rare, and when they do appear, there are even fewer cases with multiple interpretations, and these rarely match exactly in argument type. Since function types have a much more complex representation (with multiple parameters and assertions) than data types, checking equality on them also takes longer. |
---|
215 | |
---|
216 | A brief test of this approach shows that the number of function overloads considered in expression resolution increases by an amount of less than 1 percent, while type comparisons in candidate elimination are reduced by more than half. This improvement is consistent over all \CFA source files in the test suite. |
---|
217 | |
---|
218 | |
---|
219 | \subsection{Shared sub-expression case: Unique expressions} |
---|
220 | \label{s:SharedSub-ExpressionCaseUniqueExpressions} |
---|
221 | |
---|
222 | Unique expression denotes an expression evaluated only once to prevent unwanted side effects. It is currently only a compiler artifact, generated for tuple-member expression of the form: |
---|
223 | \begin{cfa} |
---|
224 | struct S { int a; int b; }; |
---|
225 | S s; |
---|
226 | s.[a, b]; // tuple member expression, type is [int, int] |
---|
227 | \end{cfa} |
---|
228 | If the aggregate expression is function call, it cannot be evaluated multiple times: |
---|
229 | \begin{cfa} |
---|
230 | S makeS(); |
---|
231 | makeS().[a, b]; // this should only generate a unique S |
---|
232 | \end{cfa} |
---|
233 | Before code generation, the above expression is internally represented as |
---|
234 | \begin{cfa} |
---|
235 | [uniqueExpr{makeS()}.a, uniqueExpr{makeS()}.b]; |
---|
236 | \end{cfa} |
---|
237 | and the unique expression is expanded to$\footnote{This uses gcc's statement expression syntax, where \lstinline|(\{block\})| evaluates to value of last expression in the block.}$ |
---|
238 | \begin{cfa} |
---|
239 | ({ |
---|
240 | if ( !_unique_var_evaluated ) { |
---|
241 | _unique_var_evaluated = true; |
---|
242 | _unique_var = makeS(); |
---|
243 | } |
---|
244 | _unique_var; |
---|
245 | }) |
---|
246 | \end{cfa} |
---|
247 | at code generation, where @_unique_var@ and @_unique_var_evaluated@ are generated variables whose scope covers all appearances of the same expression. |
---|
248 | The conditional check ensures a single call to @makeS()@ even though there are logically multiple calls because of the tuple field expansion. |
---|
249 | |
---|
250 | Note that although the unique expression is only used for tuple expansion now, it is a generally useful construction, and is seen in other programming languages, such as Scala's @lazy val@~\cite{Scala}; therefore it may be worthwhile to introduce the unique expression to a broader context in \CFA and even make it directly available to programmers. |
---|
251 | |
---|
252 | In the compiler's visitor pattern, however, this creates a problem where multiple paths to a logically unique expression exist, so it may be modified more than once and become ill-formed; some specific intervention is required to ensure unique expressions are only visited once. Furthermore, a unique expression appearing in more than one places is copied on mutation so its representation is no longer unique. |
---|
253 | |
---|
254 | Currently, special cases are required to keep everything synchronized, and the methods are different when mutating the unique expression instance itself or its underlying expression: |
---|
255 | \begin{itemize} |
---|
256 | \item |
---|
257 | When mutating the underlying expression (visit-once guard) |
---|
258 | \begin{cfa} |
---|
259 | void InsertImplicitCalls::previsit( const ast::UniqueExpr * unqExpr ) { |
---|
260 | @if ( visitedIds.count( unqExpr->id ) ) visit_children = false;@ |
---|
261 | else visitedIds.insert( unqExpr->id ); |
---|
262 | } |
---|
263 | \end{cfa} |
---|
264 | \item |
---|
265 | When mutating the unique instance itself, which actually creates copies |
---|
266 | \begin{cfa} |
---|
267 | auto mutExpr = mutate( unqExpr ); // internally calls copy when shared |
---|
268 | @if ( ! unqMap.count( unqExpr->id ) ) {@ |
---|
269 | ... |
---|
270 | } else { |
---|
271 | mutExpr->expr = unqMap[mutExpr->id]->expr; |
---|
272 | mutExpr->result = mutExpr->expr->result; |
---|
273 | } |
---|
274 | \end{cfa} |
---|
275 | \end{itemize} |
---|
276 | Such workarounds are difficult to fit into the common visitor pattern, which suggests the memory model may need different kinds of nodes to accurately represent this feature in the AST. |
---|
277 | |
---|
278 | Given that declaration nodes are unique, it is possible for AST nodes to be divided into three different types: |
---|
279 | \begin{itemize} |
---|
280 | \item |
---|
281 | \textbf{Singleton} with only one owner (declarations); |
---|
282 | \item |
---|
283 | \textbf{No-copy} with multiple owners but cannot be copied (unique expression example presented here); |
---|
284 | \item |
---|
285 | \textbf{Copy} by functional-programming style, which assumes immutable data structures that are copied on mutation. |
---|
286 | \end{itemize} |
---|
287 | The boilerplate code can potentially handle these three cases differently. |
---|
288 | |
---|
289 | |
---|
290 | \section{Analysis of resolver algorithm complexity} |
---|
291 | |
---|
292 | The focus of this section is to identify and analyze some realistic cases that cause the resolver algorithm to have an exponential runtime. As previous work has shown~\cite[\S~4.2.1]{Moss19}, the overload resolution problem in \CFA has worst-case exponential complexity; however, only few specific patterns can trigger the exponential complexity in practice. Implementing heuristic-based optimization for those selected cases is helpful to alleviate the problem. |
---|
293 | |
---|
294 | |
---|
295 | \subsection{Unbound return type} |
---|
296 | \label{s:UnboundReturnType} |
---|
297 | |
---|
298 | The interaction of return-type overloading and polymorphic functions creates function calls with unbounded return-type, and is further complicated by the presence of assertions. |
---|
299 | The prime example of a function with unbound return type is the type-safe version of C @malloc@: |
---|
300 | \begin{cfa} |
---|
301 | forall( dtype T | sized( T ) ) |
---|
302 | T * malloc( void ) { return (T *)malloc( sizeof(T) ); } // call C malloc |
---|
303 | int * i = malloc(); // type deduced from left-hand size $\(\Rightarrow\)$ no size argument or return cast |
---|
304 | \end{cfa} |
---|
305 | An unbound return-type is problematic in resolver complexity because a single match of a function call with an unbound return type may create multiple candidates. In the worst case, consider a function declared that returns any @otype@ (defined \VPageref{otype}): |
---|
306 | \begin{cfa} |
---|
307 | forall( otype T ) T anyObj( void ); |
---|
308 | \end{cfa} |
---|
309 | As the resolver attempts to satisfy the otype constraint on @T@, a call to @anyObj()@ in an expression, without the result type known, creates at least as many candidates as the number of complete types currently in scope; with generic types it becomes even worse, \eg assuming a declaration of a generic @pair@ is available at that point: |
---|
310 | \begin{cfa} |
---|
311 | forall( otype T, otype U ) struct pair { T first; U second; }; |
---|
312 | \end{cfa} |
---|
313 | Then an @anyObj()@ call can result in arbitrarily complex types, such as @pair( pair( int, int ), pair( int, int ) )@, and the depth can grow indefinitely until a specified parameter-depth limit, thus creating exponentially many candidates. However, the expected types allowed by parent expressions are practically very few, so most of those interpretations are invalid; if the result type is never bound up to the top level, by the semantic rules it is ambiguous if there is more than one valid binding and resolution fails quickly. It is therefore reasonable to delay resolving assertions on an unbound parameter in a return type; however, with the current cost model, such behavior may further cause irregularities in candidate selection, such that the presence of assertions can change the preferred candidate, even when order of expression costs are supposed to stay the same. A detailed analysis of this issue is presented in \VRef{s:AnalysisTypeSystemCorrectness}. |
---|
314 | |
---|
315 | |
---|
316 | \subsection[ttype resolution infinite recursion]{\lstinline|ttype| resolution infinite recursion} |
---|
317 | \label{s:TtypeResolutionInfiniteRecursion} |
---|
318 | |
---|
319 | @ttype@ (``tuple type'') is a relatively new addition to the language that attempts to provide type-safe variadic argument semantics. Unlike regular @dtype@ parameters, @ttype@ is only valid in a function parameter-list, and may only appear once as the last parameter type. At the call site, a @ttype@ parameter is bound to the tuple type of all remaining function-call arguments. |
---|
320 | |
---|
321 | There are two kinds of idiomatic @ttype@ usage: one is to provide flexible argument forwarding, similar to the variadic template in \CC (\lstinline[language=C++]|template<typename... args>|), as shown below in the implementation of @unique_ptr@ |
---|
322 | \begin{cfa} |
---|
323 | forall( dtype T ) |
---|
324 | struct unique_ptr { |
---|
325 | T * data; |
---|
326 | }; |
---|
327 | forall( dtype T | sized( T ), @ttype Args@ | { void ?{}( T &, Args ); }) |
---|
328 | void ?{}( unique_ptr( T ) & this, Args @args@ ) { |
---|
329 | this.data = new( @args@ ); // forward constructor arguments to dynamic allocator |
---|
330 | } |
---|
331 | \end{cfa} |
---|
332 | The other usage is to implement structural recursion in the first-rest pattern: |
---|
333 | \begin{cfa} |
---|
334 | forall( otype T, @ttype Params@ | { void process( T ); void func( Params ); }) |
---|
335 | void func( T arg1, Params p ) { |
---|
336 | process( arg1 ); |
---|
337 | func( @p@ ); // recursive call until base case of one argument |
---|
338 | } |
---|
339 | \end{cfa} |
---|
340 | For the second use case, it is imperative the number of parameters in the recursive call goes down, since the call site must deduce all assertion candidates, and that is only possible if by observation of the argument types (and not their values), the recursion is known to be completed in a finite number of steps. |
---|
341 | |
---|
342 | In recent experiments, however, a flaw in the type-binding rules can lead to the first kind of @ttype@ use case producing an invalid candidate and the resolver enters an infinite loop. |
---|
343 | This bug was discovered in an attempt to raise the assertion recursive-depth limit and one of the library programs took exponentially longer to compile. The cause of the problem is the following set of functions: |
---|
344 | \begin{cfa} |
---|
345 | // unique_ptr declaration from above |
---|
346 | |
---|
347 | forall( dtype T | sized( T ), ttype Args | { void ?{}( T &, Args ); } ) { // distribute forall clause |
---|
348 | void ?{}( counter_data( T ) & this, Args args ); |
---|
349 | void ?{}( counter_ptr( T ) & this, Args args ); |
---|
350 | void ?{}( unique_ptr( T ) & this, Args args ); |
---|
351 | } |
---|
352 | |
---|
353 | forall( dtype T | sized( T ), ttype TT | { void ?{}( T &, TT ); } ) |
---|
354 | T * new( TT p ) { return @&(*malloc()){ p };@ } |
---|
355 | \end{cfa} |
---|
356 | In the expression @(*malloc()){p}@, the type of the object being constructed is unknown, since the return-type information is not immediately available. That causes every constructor to be searched, and while normally a bound @ttype@ cannot be unified with any free parameter, it is possible with another free @ttype@. Therefore, in addition to the correct option provided by the assertion, 3 wrong options are examined, each of which again requires the same assertion, for an unknown base-type @T@ and @ttype@ argument, which becomes an infinite loop until the specified recursion limit and resolution is fails. Moreover, during the recursion steps, the number of candidates grows exponentially, since there are always 3 options at each step. |
---|
357 | |
---|
358 | Unfortunately, @ttype@ to @ttype@ binding is necessary, to allow indirectly calling a function provided in an assertion. |
---|
359 | \begin{cfa} |
---|
360 | forall( dtype T | sized( T ), ttype Args | { @void ?{}( T &, Args );@ }) |
---|
361 | void ?{}( unique_ptr( T ) & this, Args args ) { this.data = (T *)@new( args )@; } // constructor call |
---|
362 | \end{cfa} |
---|
363 | Here the constructor assertion is used by the @new( args )@ call to indirectly call the constructor on the allocated storage. |
---|
364 | Therefore, it is hard, perhaps impossible, to solve this problem by tweaking the type binding rules. An assertion caching algorithm can help improve this case by detecting cycles in recursion. |
---|
365 | |
---|
366 | Meanwhile, without a caching algorithm implemented, some changes in the \CFA source code are enough to eliminate this problem, at least in the current codebase. Note that the issue only happens with an overloaded variadic function, which rarely appears in practice, since the idiomatic use cases are for argument forwarding and self-recursion. The only overloaded @ttype@ function so far discovered in all of \CFA standard library is the constructor, and by utilizing the argument-dependent lookup process described in \VRef{s:UnboundReturnType}, adding a cast before the constructor call removes the issue. |
---|
367 | \begin{cfa} |
---|
368 | T * new( TT p ) { return &(*@(T * )@malloc()){ p }; } |
---|
369 | \end{cfa} |
---|
370 | |
---|
371 | |
---|
372 | \subsection{Reused assertions in nested generic type} |
---|
373 | |
---|
374 | The following test of deeply nested, dynamic generic type reveals that locally caching reused assertions is necessary, rather than just a resolver optimization, because recomputing assertions can result in bloated generated code size: |
---|
375 | \begin{cfa} |
---|
376 | struct nil {}; |
---|
377 | forall( otype L, otype R ) |
---|
378 | struct cons { L l; R r; }; |
---|
379 | |
---|
380 | int main() { |
---|
381 | #if N==0 |
---|
382 | nil @x@; |
---|
383 | #elif N==1 |
---|
384 | cons( size_t, nil ) @x@; |
---|
385 | #elif N==2 |
---|
386 | cons( size_t, cons( size_t, nil ) ) @x@; |
---|
387 | #elif N==3 |
---|
388 | cons( size_t, cons( size_t, cons( size_t, nil ) ) ) @x@; |
---|
389 | // similarly for N=4,5,6 |
---|
390 | #endif |
---|
391 | } |
---|
392 | \end{cfa} |
---|
393 | At the declaration of @x@, it is implicitly initialized by generated constructor call, with signature: |
---|
394 | \begin{cfa} |
---|
395 | forall( otype L, otype R ) void ?{}( cons( L, R ) & ); |
---|
396 | \end{cfa} |
---|
397 | where the @otype@ constraint contains the 4 assertions:\label{otype} |
---|
398 | \begin{cfa} |
---|
399 | void ?{}( L & ); // default constructor |
---|
400 | void ?{}( L &, L & ); // copy constructor |
---|
401 | void ^?{}( L & ); // destructor |
---|
402 | L & ?=?( L &, L & ); // assignment |
---|
403 | \end{cfa} |
---|
404 | |
---|
405 | \begin{table}[htb] |
---|
406 | \centering |
---|
407 | \caption{Compilation results of nested cons test} |
---|
408 | \label{t:NestedConsTest} |
---|
409 | \begin{tabular}{|r|r|r|} |
---|
410 | \hline |
---|
411 | N & Assertions resolved & Binary size (KB) \\ |
---|
412 | \hline |
---|
413 | 3 & 170 & 70 \\ |
---|
414 | \hline |
---|
415 | 4 & 682 & 219 \\ |
---|
416 | \hline |
---|
417 | 5 & 2,730 & 829 \\ |
---|
418 | \hline |
---|
419 | 6 & 10,922 & 3,261 \\ |
---|
420 | \hline |
---|
421 | \end{tabular} |
---|
422 | \end{table} |
---|
423 | |
---|
424 | Now since the right hand side of outermost cons is again a cons, recursive assertions are required. \VRef[Table]{t:NestedConsTest} shows when the compiler does not cache and reuse already resolved assertions, it becomes a problem, as each of these 4 pending assertions again asks for 4 more assertions one level below. Without caching, the number of resolved assertions grows exponentially, which is unnecessary since there are only $n+1$ different types involved. Even worse, this problem causes exponentially many wrapper functions to be generated at the backend, resulting in a huge binary. As the local functions are implemented by emitting executable code on the stack~\cite{gcc-nested-func}, it means that compiled code also has exponential run time. This problem has practical implications, as nested collection types are frequently used in real production code. |
---|
425 | |
---|
426 | \section{Analysis of type system correctness} |
---|
427 | \label{s:AnalysisTypeSystemCorrectness} |
---|
428 | |
---|
429 | In Moss' thesis~\cite[\S~4.1.2,~p.~45]{Moss19}, the author presents the following example: |
---|
430 | \begin{quote} |
---|
431 | The cost model of \CFA precludes a greedy bottom-up resolution pass, as constraints and costs introduced by calls higher in the expression tree can change the interpretation of those lower in the tree, as in the following example: |
---|
432 | \begin{cfa} |
---|
433 | void f( int ); |
---|
434 | double g$\(_1\)$( int ); |
---|
435 | int g$\(_2\)$( long ); |
---|
436 | f( g( 42 ) ); |
---|
437 | \end{cfa} |
---|
438 | Considered independently, @g$_1$( 42 )@ is the cheapest interpretation of @g( 42 )@, with cost (0, 0, 0, 0, 0, 0, 0) since the argument type is an exact match. However, in context, an unsafe conversion is required to downcast the return type of @g1@ to an @int@ suitable for @f@, for a total cost of (1, 0, 0, 0, 0, 0, 0) for @f( g1( 42 ) )@. If @g2@ is chosen, on the other hand, there is a safe upcast from the int type of 42 to @long@, but no cast on the return of @g2@, for a total cost of (0, 0, 1, 0, 0, 0, 0) for @f( g2( 42 ) );@ as this is cheaper, @g2@ is chosen. Due to this design, all valid interpretations of subexpressions must in general be propagated to the top ... |
---|
439 | \end{quote} |
---|
440 | The cost model in the thesis sums up all costs in resolving sub-expressions and chooses the global optimal option. However, the current implementation in @cfa-cc@, based on the original Bilson model~\cite[\S~2.2.4,~p.~32]{Bilson03}, does eager resolution and only allows local optimal candidate selections to propagate upwards: |
---|
441 | \begin{quote} |
---|
442 | From the set of candidates whose parameter and argument types have been unified and whose assertions have been satisfied, those whose sub-expression interpretations have the smallest total cost of conversion are selected ... The total cost of conversion for each of these candidates is then calculated based on the implicit conversions and polymorphism involved in adapting the types of the sub-expression interpretations to the formal parameter types. |
---|
443 | \end{quote} |
---|
444 | With this model, the algorithm picks @g1@ in resolving the @f( g( 42 ) )@ call, which is undesirable. |
---|
445 | |
---|
446 | There is further evidence that shows the Bilson model is fundamentally incorrect, following the discussion of unbound return type in \VRef{s:UnboundReturnType}. By the conversion-cost specification, a binding from a polymorphic type-parameter to a concrete type incurs a polymorphic cost of 1. It remains unspecified \emph{when} the type parameters should become bound. When the parameterized types appear in function parameters, they can be deduced from the argument type, and there is no ambiguity. In the unbound return case, however, the binding may happen at any stage in expression resolution, therefore it is impossible to define a unique local conversion cost. Note that type binding happens exactly once per parameter in resolving the entire expression, so the global binding cost is unambiguously 1. |
---|
447 | |
---|
448 | In the current compiler implementation, there is a notable inconsistency in handling this case. For any unbound parameter that does \emph{not} come with an associated assertion, it remains unbound to the parent expression; for those that do, however, they are immediately bound in the assertion resolution step, and concrete result types are used in the parent expressions. |
---|
449 | Consider the following example: |
---|
450 | \begin{cfa} |
---|
451 | forall( dtype T ) T * f( void ); |
---|
452 | void h( int * ); |
---|
453 | \end{cfa} |
---|
454 | The expression @h( f() )@ eventually has a total cost of 1 from binding (T: int), but in the eager-resolution model, the cost of 1 may occur either at the call to @f@ or at call to @h@, and with the assertion resolution triggering a binding, the local cost of @f()@ is (0 poly, 0 spec) with no assertions, but (1 poly, -1 spec) with an assertion: |
---|
455 | \begin{cfa} |
---|
456 | forall( dtype T | @{ void g( T * ); }@ ) T * f( void ); |
---|
457 | void g( int * ); |
---|
458 | void h( int * ); |
---|
459 | \end{cfa} |
---|
460 | and that contradicts the principle that adding assertions should make expression cost lower. Furthermore, the time at which type binding and assertion resolution happens is an implementation detail of the compiler, not part of the language definition. That means two compliant \CFA compilers, one performing immediate assertion resolution at each step, and one delaying assertion resolution on unbound types, can produce different expression costs and therefore different candidate selection, making the language rule itself partially undefined, and therefore, unsound. By the above reasoning, the updated cost model using global sum of costs should be accepted as the standard. It also allows the compiler to freely choose when to resolve assertions, as the sum of total costs is independent of that choice; more optimizations regarding assertion resolution can also be implemented. |
---|
461 | |
---|
462 | |
---|
463 | \section{Timing results} |
---|
464 | |
---|
465 | For the timing results presented here, the \CFA compiler is built with gcc 9.3.0, and tested on a server machine running Ubuntu 20.04, 64GB RAM and 32-core 2.2 GHz CPU. |
---|
466 | Timing is reported by the @time@ command and an experiment is run using 8 cores, where each core is at 100\% CPU utilization. |
---|
467 | |
---|
468 | On the most recent build, the \CFA standard library ($\approx$1.3 MB of source code) compiles in 4 minutes 47 seconds total processor time (single thread equivalent), with the slowest file taking 13 seconds. The test suite (178 test cases, $\approx$2.2MB of source code) completes within 25 minutes total processor time, |
---|
469 | % PAB: I do not understand this footnote. |
---|
470 | %\footnote{Including a few runtime tests; total time spent in compilation is approximately 21 minutes.} |
---|
471 | with the slowest file taking 23 seconds. In contrast, the library build with the old compiler takes 85 minutes total, 5 minutes for the slowest file. The full test-suite takes too long with old compiler build and is therefore not run, but the slowest test cases take approximately 5 minutes. Overall, the most recent build compared to an old build is consistently faster by a factor of 20. |
---|
472 | |
---|
473 | Additionally, 6 selected \CFA source files with distinct features from the library and test suite are used to illustrate the compiler performance change after each of the implemented optimizations. Test files are from the most recent build and run through the C preprocessor to expand header file, perform macro expansions, but no line number information (@gcc -E -P@). |
---|
474 | \VRef[Table]{t:SelectedFileByCompilerBuild} shows the selected tests: |
---|
475 | \begin{itemize} |
---|
476 | \item |
---|
477 | @lib/fstream@ (112 KB) |
---|
478 | \item |
---|
479 | @lib/mutex@ (166 KB): implementation of concurrency primitive |
---|
480 | \item |
---|
481 | @lib/vector@ (43 KB): container example, similar to \CC vector |
---|
482 | \item |
---|
483 | @lib/stdlib@ (64 KB): type-safe wrapper to @void *@-based C standard library functions |
---|
484 | \item |
---|
485 | @test/io2@ (55 KB): application of I/O library |
---|
486 | \item |
---|
487 | @test/thread@ (188 KB): application of threading library |
---|
488 | \end{itemize} |
---|
489 | versus \CFA compiler builds picked from the git commit history that implement the optimizations incrementally: |
---|
490 | \begin{itemize} |
---|
491 | \item |
---|
492 | old resolver |
---|
493 | \item |
---|
494 | \#0 is the first working build of the new AST data structure |
---|
495 | \item |
---|
496 | \#1 implements special symbol table and argument-dependent lookup |
---|
497 | \item |
---|
498 | \#2 implements late assertion-satisfaction |
---|
499 | \item |
---|
500 | \#3 implements revised function-type representation |
---|
501 | \item |
---|
502 | \#4 skips pruning on expressions for function types (most recent build) |
---|
503 | \end{itemize} |
---|
504 | Reading left to right for a test shows the benefit of each optimization on the cost of compilation. |
---|
505 | |
---|
506 | \begin{table}[htb] |
---|
507 | \centering |
---|
508 | \caption{Compile time of selected files by compiler build, in seconds} |
---|
509 | \label{t:SelectedFileByCompilerBuild} |
---|
510 | \begin{tabular}{|l|r|r|r|r|r|r|} |
---|
511 | \hline |
---|
512 | Test case & old & \#0 & \#1 & \#2 & \#3 & \#4 \\ |
---|
513 | \hline |
---|
514 | @lib/fstream@ & 86.4 & 25.2 & 10.8 & 9.5 & 7.8 & 7.1 \\ |
---|
515 | \hline |
---|
516 | @lib/mutex@ & 210.4 & 77.4 & 16.7 & 15.1 & 12.6 & 11.7 \\ |
---|
517 | \hline |
---|
518 | @lib/vector@ & 17.2 & 8.9 & 3.1 & 2.8 & 2.4 & 2.2 \\ |
---|
519 | \hline |
---|
520 | @lib/stdlib@ & 16.6 & 8.3 & 3.2 & 2.9 & 2.6 & 2.4 \\ |
---|
521 | \hline |
---|
522 | @test/io2@ & 300.8 & 53.6 & 43.2 & 27.9 & 19.1 & 16.3 \\ |
---|
523 | \hline |
---|
524 | @test/thread@ & 210.9 & 73.5 & 17.0 & 15.1 & 12.6 & 11.8 \\ |
---|
525 | \hline |
---|
526 | \end{tabular} |
---|
527 | \smallskip |
---|
528 | \newline |
---|
529 | Results are average of 5 runs (3 runs if time exceeds 100 seconds) |
---|
530 | \end{table} |
---|
531 | |
---|
532 | \section{Conclusion} |
---|
533 | |
---|
534 | Over the course of 8 months of active research and development of the \CFA type system and compiler algorithms, performance of the reference \CFA compiler, cfa-cc, has been greatly improved. Now, mid-sized \CFA programs are compiled reasonably fast. Currently, there are ongoing efforts by the \CFA team to augment the standard library and evaluate its runtime performance, and incorporate \CFA with existing software written in C; therefore this project is especially meaningful for these practical purposes. |
---|
535 | |
---|
536 | Accomplishing this work was difficult. Analysis conducted in the project is based significantly on heuristics and practical evidence, as the theoretical bounds and average cases for the expression resolution problem differ. As well, the slowness of the initial compiler made attempts to understand why and where problems exist extremely difficult because both debugging and validation tools (\eg @gdb@, @valgrind@, @pref@) further slowed down compilation time. However, by the end of the project, I had found and fixed several significant problems and new optimizations are easier to introduce and test. The reduction in the development cycle benefits the \CFA team as a whole. |
---|
537 | |
---|
538 | Some potential issues of the language, which happen frequently in practice, have been identified. Due to the time constraint and complex nature of these problems, a handful of them remain unsolved, but some constructive proposals are made. Notably, introducing a local assertion cache in the resolver is a reasonable solution for a few remaining problems, so that should be the focus of future work. |
---|
539 | |
---|
540 | The \CFA team are planning on a public alpha release of the language as the compiler performance, given my recent improvements, is now useable. Other parts of the system, such as the standard library, have made significant gains due to the speed up in the development cycle. Ideally, the remaining problems should be resolved before release, and the solutions will also be integral to drafting a formal specification. |
---|
541 | |
---|
542 | \addcontentsline{toc}{section}{\refname} |
---|
543 | \bibliographystyle{plain} |
---|
544 | \bibliography{pl} |
---|
545 | |
---|
546 | \end{document} |
---|
547 | |
---|
548 | % Local Variables: % |
---|
549 | % tab-width: 4 % |
---|
550 | % fill-column: 100 % |
---|
551 | % compile-command: "make" % |
---|
552 | % End: % |
---|