Lecture Notes in Computer Science Edited by G. Goos, J. Hartmanis, and J. van Leeuwen
2304
3
Berlin Heidelberg New York Barcelona Hong Kong London Milan Paris Tokyo
R. Nigel Horspool (Ed.)
Compiler Construction 11th International Conference, CC 2002 Held as Part of the Joint European Conferences on Theory and Practice of Software, ETAPS 2002 Grenoble, France, April 8-12, 2002 Proceedings
13
Series Editors Gerhard Goos, Karlsruhe University, Germany Juris Hartmanis, Cornell University, NY, USA Jan van Leeuwen, Utrecht University, The Netherlands Volume Editor R. Nigel Horspool University of Victoria, Dept. of Computer Science Victoria, BC, Canada V8W 3P6 E-mail:
[email protected] Cataloging-in-Publication Data applied for Die Deutsche Bibliothek - CIP-Einheitsaufnahme Compiler construction : 11th international conference ; proceedings / CC 2002, held as part of the Joint European Conferences on Theory and Practice of Software, ETAPS 2002, Grenoble, France, April 8 - 12, 2002. R. Nigel Horspool (ed.). - Berlin ; Heidelberg ; New York ; Barcelona ; Hong Kong ; London ; Milan ; Paris ; Tokyo : Springer, 2002 (Lecture notes in computer science ; Vol. 2304) ISBN 3-540-43369-4
CR Subject Classification (1998): D.3.4, D.3.1, F.4.2, D.2.6, I.2.2, F.3 ISSN 0302-9743 ISBN 3-540-43369-4 Springer-Verlag Berlin Heidelberg New York This work is subject to copyright. All rights are reserved, whether the whole or part of the material is concerned, specifically the rights of translation, reprinting, re-use of illustrations, recitation, broadcasting, reproduction on microfilms or in any other way, and storage in data banks. Duplication of this publication or parts thereof is permitted only under the provisions of the German Copyright Law of September 9, 1965, in its current version, and permission for use must always be obtained from Springer-Verlag. Violations are liable for prosecution under the German Copyright Law. Springer-Verlag Berlin Heidelberg New York a member of BertelsmannSpringer Science+Business Media GmbH http://www.springer.de © Springer-Verlag Berlin Heidelberg 2002 Printed in Germany Typesetting: Camera-ready by author, data conversion by DA-TeX Gerd Blumenstein Printed on acid-free paper SPIN 10846505 06/3142 543210
Foreword
ETAPS 2002 was the fifth instance of the European Joint Conferences on Theory and Practice of Software. ETAPS is an annual federated conference that was established in 1998 by combining a number of existing and new conferences. This year it comprised 5 conferences (FOSSACS, FASE, ESOP, CC, TACAS), 13 satellite workshops (ACL2, AGT, CMCS, COCV, DCC, INT, LDTA, SC, SFEDL, SLAP, SPIN, TPTS, and VISS), 8 invited lectures (not including those specific to the satellite events), and several tutorials. The events that comprise ETAPS address various aspects of the system development process, including specification, design, implementation, analysis, and improvement. The languages, methodologies, and tools which support these activities are all well within its scope. Different blends of theory and practice are represented, with an inclination towards theory with a practical motivation on one hand and soundly-based practice on the other. Many of the issues involved in software design apply to systems in general, including hardware systems, and the emphasis on software is not intended to be exclusive. ETAPS is a loose confederation in which each event retains its own identity, with a separate program committee and independent proceedings. Its format is open-ended, allowing it to grow and evolve as time goes by. Contributed talks and system demonstrations are in synchronized parallel sessions, with invited lectures in plenary sessions. Two of the invited lectures are reserved for “unifying” talks on topics of interest to the whole range of ETAPS attendees. The aim of cramming all this activity into a single one-week meeting is to create a strong magnet for academic and industrial researchers working on topics within its scope, giving them the opportunity to learn about research in related areas, and thereby to foster new and existing links between work in areas that were formerly addressed in separate meetings. ETAPS 2002 was organized by the Laboratoire Verimag in cooperation with Centre National de la Recherche Scientifique (CNRS) Institut de Math´ematiques Appliqu´ees de Grenoble (IMAG) Institut National Polytechnique de Grenoble (INPG) Universit´e Joseph Fourier (UJF) European Association for Theoretical Computer Science (EATCS) European Association for Programming Languages and Systems (EAPLS) European Association of Software Science and Technology (EASST) ACM SIGACT, SIGSOFT, and SIGPLAN
VI
Foreword
The organizing team comprised Susanne Graf - General Chair Saddek Bensalem - Tutorials Rachid Echahed - Workshop Chair Jean-Claude Fernandez - Organization Alain Girault - Publicity Yassine Lakhnech - Industrial Relations Florence Maraninchi - Budget Laurent Mounier - Organization Overall planning for ETAPS conferences is the responsibility of its Steering Committee, whose current membership is: Egidio Astesiano (Genova), Ed Brinksma (Twente), Pierpaolo Degano (Pisa), Hartmut Ehrig (Berlin), Jos´e Fiadeiro (Lisbon), Marie-Claude Gaudel (Paris), Andy Gordon (Microsoft Research, Cambridge), Roberto Gorrieri (Bologna), Susanne Graf (Grenoble), John Hatcliff (Kansas), G¨ orel Hedin (Lund), Furio Honsell (Udine), Nigel Horspool (Victoria), Heinrich Hußmann (Dresden), Joost-Pieter Katoen (Twente), Paul Klint (Amsterdam), Daniel Le M´etayer (Trusted Logic, Versailles), Ugo Montanari (Pisa), Mogens Nielsen (Aarhus), Hanne Riis Nielson (Copenhagen), Mauro Pezz`e (Milan), Andreas Podelski (Saarbr¨ ucken), Don Sannella (Edinburgh), Andrzej Tarlecki (Warsaw), Herbert Weber (Berlin), Reinhard Wilhelm (Saarbr¨ ucken) I would like to express my sincere gratitude to all of these people and organizations, the program committee chairs and PC members of the ETAPS conferences, the organizers of the satellite events, the speakers themselves, and finally Springer-Verlag for agreeing to publish the ETAPS proceedings. As organizer of ETAPS’98, I know that there is one person that deserves a special applause: Susanne Graf. Her energy and organizational skills have more than compensated for my slow start in stepping into Don Sannella’s enormous shoes as ETAPS Steering Committee chairman. Yes, it is now a year since I took over the role, and I would like my final words to transmit to Don all the gratitude and admiration that is felt by all of us who enjoy coming to ETAPS year after year knowing that we will meet old friends, make new ones, plan new projects and be challenged by a new culture! Thank you Don! January 2002
Jos´e Luiz Fiadeiro
Preface
Once again, the number, the breadth and the quality of papers submitted to the CC 2002 conference continues to be impressive. In spite of some difficult times which may have discouraged many potential authors from thinking of travelling to a conference, we still received 44 submissions. Of these submissions, 21 came from 12 different European countries, 17 from the USA and Canada, and the remaining 6 from Australia and Asia. In addition to the regular paper submissions, we have an invited paper from Patrick and Radhia Cousot. It is especially fitting that Patrick Cousot should deliver the CC 2002 invited paper in Grenoble because many years ago he wrote his PhD thesis at the University of Grenoble. The members of the Program Committee took their refereeing task very seriously and decided very early on that a physical meeting was necessary to make the selection process as fair as possible. Accordingly, nine members of the Program Committee attended a meeting in Austin, Texas, on December 1, 2001, where the difficult decisions were made. Three others joined in the deliberations via a telephone conference call. Eventually, and after much (friendly) argument, 18 papers were selected for publication. I wish to thank the Program Committee members for their selfless dedication and their excellent advice. I especially want to thank Kathryn McKinley and her assistant, Gem Naivar, for making the arrangements for the PC meeting. I also wish to thank my assistant, Catherine Emond, for preparing the materials for the PC meeting and for assembling the manuscript of the proceedings. The paper submissions and the reviewing process were supported by the START system (http://www.softconf.com). I thank the author of START, Rich Gerber, for making his software available to CC 2002 and for his prompt attention to the little problems that arose. These conference proceedings include the invited paper of Patrick and Radhia Cousot, the 18 regular papers, and brief descriptions of three software tools.
January 2002
Nigel Horspool
VIII
Preface
Program Committee Uwe Aßmann (Linkopings Universitet, Sweden) David Bernstein (IBM Haifa, Israel) Judith Bishop (University of Pretoria, South Africa) Ras Bodik (University of Wisconsin-Madison, USA) Cristina Cifuentes (Sun Microsystems, USA) Christian Collberg (University of Arizona, USA) Stefano Crespi-Reghizzi (Politecnico di Milano, Italy) Michael Franz (University of California at Irvine, USA) Andreas Krall (Technical University of Vienna, Austria) Reiner Leupers (University of Dortmund, Germany) Kathryn McKinley (University of Texas at Austin, USA) Nigel Horspool – Chair (University of Victoria, Canada) Todd Proebsting (Microsoft Research, USA) Norman Ramsey (Harvard University, USA)
Additional Reviewers G.P. Agosta John Aycock Jon Eddy Anton Ertl Marco Garatti G¨ orel Hedin Won Kee Hong Bruce Kapron Moshe Klausner Annie Liu V. Martena Bilha Mendelson Sreekumar Nair
Dorit Naishloss Ulrich Neumerkel Mark Probst Fermin Reig P.L. San Pietro Bernhard Scholz Glenn Skinner Phil Tomsich David Ung Mike Van Emmerik JingLing Xue Yaakov Yaari Ayal Zaks
Table of Contents
Tool Demonstrations LISA: An Interactive Environment for Programming Language Development . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .1 ˇ Marjan Mernik, Mitja Leniˇc, Enis Avdiˇcauˇsevi´c, and Viljem Zumer Building an Interpreter with Vmgen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5 M. Anton Ertl and David Gregg Compiler Construction Using LOTOS NT . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9 Hubert Garavel, Fr´ed´eric Lang, and Radu Mateescu
Analysis and Optimization Data Compression Transformations for Dynamically Allocated Data Structures . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .14 Youtao Zhang and Rajiv Gupta Evaluating a Demand Driven Technique for Call Graph Construction . . . . . . 29 Gagan Agrawal, Jinqian Li, and Qi Su A Graph–Free Approach to Data–Flow Analysis . . . . . . . . . . . . . . . . . . . . . . . . . . . 46 Markus Mohnen A Representation for Bit Section Based Analysis and Optimization . . . . . . . . .62 Rajiv Gupta, Eduard Mehofer, and Youtao Zhang
Low-Level Analysis Online Subpath Profiling . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 78 David Oren, Yossi Matias, and Mooly Sagiv Precise Exception Semantics in Dynamic Compilation . . . . . . . . . . . . . . . . . . . . . . 95 Michael Gschwind and Erik Altman Decompiling Java Bytecode: Problems, Traps and Pitfalls . . . . . . . . . . . . . . . . . 111 Jerome Miecznikowski and Laurie Hendren
Grammars and Parsing Forwarding in Attribute Grammars for Modular Language Design . . . . . . . . .128 Eric Van Wyk, Oege de Moor, Kevin Backhouse, and Paul Kwiatkowski
X
Table of Contents
Disambiguation Filters for Scannerless Generalized LR Parsers . . . . . . . . . . . . 143 Mark G. J. van den Brand, Jeroen Scheerder, Jurgen J. Vinju, and Eelco Visser
Invited Talk Modular Static Program Analysis . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 159 Patrick Cousot and Radhia Cousot
Domain-Specific Languages and Tools StreamIt: A Language for Streaming Applications . . . . . . . . . . . . . . . . . . . . . . . . .179 William Thies, Michal Karczmarek, and Saman Amarasinghe Compiling Mercury to High-Level C Code . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 197 Fergus Henderson and Zoltan Somogyi CIL: Intermediate Language and Tools for Analysis and Transformation of C Programs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 213 George C. Necula, Scott McPeak, Shree P. Rahul, and Westley Weimer
Energy Consumption Optimizations Linear Scan Register Allocation in the Context of SSA Form and Register Constraints . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 229 Hanspeter M¨ ossenb¨ ock and Michael Pfeiffer Global Variable Promotion: Using Registers to Reduce Cache Power Dissipation . . . . . . . . . . . . . . . . . . . . . . . 247 Andrea G. M. Cilio and Henk Corporaal Optimizing Static Power Dissipation by Functional Units in Superscalar Processors . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 261 Siddharth Rele, Santosh Pande, Soner Onder, and Rajiv Gupta Influence of Loop Optimizations on Energy Consumption of Multi-bank Memory Systems . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 276 Mahmut Kandemir, Ibrahim Kolcu, and Ismail Kadayif
Loop and Array Optimizations Effective Enhancement of Loop Versioning in Java . . . . . . . . . . . . . . . . . . . . . . . . 293 Vitaly V. Mikheev, Stanislav A. Fedoseev, Vladimir V. Sukharev, and Nikita V. Lipsky
Table of Contents
XI
Value-Profile Guided Stride Prefetching for Irregular Code . . . . . . . . . . . . . . . . 307 Youfeng Wu, Mauricio Serrano, Rakesh Krishnaiyer, Wei Li, and Jesse Fang A Comprehensive Approach to Array Bounds Check Elimination for Java . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 325 Feng Qian, Laurie Hendren, and Clark Verbrugge Author Index . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .343
LISA: An Interactive Environment for Programming Language Development ˇ Marjan Mernik, Mitja Leniˇc, Enis Avdiˇcauˇsevi´c, and Viljem Zumer University of Maribor, Faculty of Electrical Engineering and Computer Science Institute of Computer Science Smetanova 17, 2000 Maribor, Slovenia
Abstract. The LISA system is an interactive environment for programming language development. From the formal language specifications of a particular programming language LISA produces a language specific environment that includes editors (a language-knowledgable editor and a structured editor), a compiler/interpreter and other graphic tools. The LISA is a set of related tools such as scanner generators, parser generators, compiler generators, graphic tools, editors and conversion tools, which are integrated by well-designed interfaces.
1
Introduction
We have developed a compiler/interpreter generator tool LISA ver 1.0 which automatically produces a compiler or an interpreter from the ordinary attribute grammar specifications [2] [8]. But in this version of the tool the incremental language development was not supported, so the language designer had to design new languages from scratch or by scavenging old specifications. Other deficiencies of ordinary attribute grammars become apparent in specifications for real programming languages. Such specifications are large, unstructured and are hard to understand, modify and maintain. The goal of the new version of the compiler/interpreter tool LISA was to dismiss deficiencies of ordinary attribute grammars. We overcome the drawbacks of ordinary attribute grammars with concepts from object-oriented programming, i.e. template and multiple inheritance [4]. With attribute grammar templates we are able to describe the semantic rules which are independent of grammar production rules. With multiple attribute grammar inheritance we are able to organize specifications in such way that specifications can be inherited and specialized from ancestor specifications. The proposed approach was successfully implemented in the compiler/interpreter generator LISA ver. 2.0 [5].
2
Architecture of the Tool LISA 2.0
LISA (Fig. 1) consists of several tools: editors, scanner generators, parser generators, compiler generators, graphic tools, and conversion tools such as fsa2rex, etc. The architecture of the system LISA is modular. Integration is achieved R. N. Horspool (Ed.): CC 2002, LNCS 2304, pp. 1–4, 2002. c Springer-Verlag Berlin Heidelberg 2002
2
Marjan Mernik et al.
with strictly defined interfaces that describe the behavior and type of integration of the modules. Each module can register actions when it is loaded into the core environment. Actions are methods accessible from the environment. These actions can be executed via class reflection. Their existence is not verified until invocation, so actions are dynamically linked with module methods. The module can be integrated in the environment as a visual or core module. Visual modules are used for the graphical user interface and visual representation of data structures. Core modules are non-visual components, such as the LISA language compiler. This approach is based on class reflection and is similar to JavaBeans technology. With class reflection (java.lang.reflect.* package) we can dynamically obtain a set of public methods and public variables of a module, so we can dynamically link module methods with actions. When the action is executed, the proper method is located and invoked with the description of the action event. With this architecture it is also possible to upgrade our system with different types of scanners, parsers and evaluators, which are presented as modules. This was achieved with a strict definition of communication data structures. Moreover, modules for scanners, parsers and evaluators use templates for code generation, which can be easily changed and improved.
Fig. 1. LISA Integrated Development Environment
From formal language definition also editors are generated. The languageknowledgable editor is a compromise between text editors and structure editors since just colors the different parts of a program (comments, operators, reserved
LISA: An Interactive Environment for Programming Language Development
3
words, etc.) to enhance understandability and readability of programs. Generated lexical, syntax and semantic analysers, also written in Java, can be compiled in an integrated environment without issuing a command to javac (Java compiler). Programs written in the newly defined language can be executed and evaluated. Users of the generated compiler/interpreter have the possibility to visually observe the work of lexical, syntax and semantic analyzers by watching the animation of finite state automata, parse and semantic tree. The animation shows the program in action and the graphical representation of finite state automata, the syntax and the semantic tree are automatically updated as the program executes. Animated visualizations help explain the inner workings of programs and are a useful tool for debugging. These features make the tool LISA very appropriate for the programming language development. LISA tool is freely available for educational institutions from: http://marcel.uni-mb.si/lisa . It is run on different platforms and require Java 2 SDK (Software Development Kits & Runtimes), version 1.2.2 or higher.
3
Applications of LISA
We have incrementally developed various small programming languages, such as PLM [3]. An application domain for which LISA is very suitable is a development of domain-specific languages. To our opinion, in the development of domain-specific languages the advantages of the formal definitions of generalpurpose languages should be exploited, taking into consideration the special nature of domain-specific languages. An appropriate methodology that considers frequent changes of domain-specific languages is needed since the language development process should be supported by modularity and abstraction in a manner that allows incremental changes as easily as possible. If incremental language development [7] is not supported, then the language designer has to design languages from scratch or by scavenging old specifications. This approach was successfully used in the design and implementation of various domain-specific languages. In [6] a design and implementation of Simple Object Description Language SODL for automatic interface creation are presented. The application domain was network applications. Since the cross network method calls slow down performance of our applications the solution was Tier to Tier Object Transport (TTOT). However, with this approach the network application development time has been increased. To enhance our productivity a new domainspecific SODL language has been designed. In [1] a design and implementation of COOL and AspectCOOL languages has been described using the LISA system. Here the application domain was aspect-oriented programming (AOP). AOP is a programming technique for modularizing concerns that crosscut the basic functionality of programs. In AOP, aspect languages are used to describe properties, which crosscut basic functionality in a clean and a modular way. AspectCOOL is an extension of the class-based object-oriented language COOL (Classroom Object-Oriented Language), which has been designed and implemented simultaneously with AspectCOOL. Both languages were formally specified with mul-
4
Marjan Mernik et al.
tiple attribute grammar inheritance, which enables us to gradually extend the languages with new features and to reuse the previously defined specifications. Our experience with these non-trivial examples shows that multiple attribute grammars inheritance is useful in managing the complexity, reusability and extensibility of attribute grammars. Huge specifications become much shorter and are easier to read and maintain.
4
Conclusion
Many applications today are written in well-understood domains. One trend in programming is to provide software development tools designed specifically to handle such applications and thus to greatly simplify their development. These tools take a high-level description of the specific task and generate a complete application. One of such well established domain is compiler construction, because there is a long tradition of producing compilers, underlying theories are well understood and there exist many application generators, which automatically produce compilers or interpreters from programming language specifications. In the paper the compiler/interpreter generator LISA 2.0 is briefly presented.
References ˇ 1. Enis Avdiˇcauˇsevi´c, Mitja Leniˇc, Marjan Mernik, and Viljem Zumer. AspectCOOL: An experiment in design and implementation of aspect-oriented language. Accepted for publications in ACM SIGPLAN Notices. 3 ˇ 2. Marjan Mernik, Nikolaj Korbar, and Viljem Zumer. LISA: A tool for automatic language implementation. ACM SIGPLAN Notices, 30(4):71–79, April 1995. 1 ˇ 3. Marjan Mernik, Mitja Leniˇc, Enis Avdiˇcauˇsevi´c, and Viljem Zumer. A reusable object-oriented approach to formal specifications of programming languages. L’Objet, 4(3):273–306, 1998. 3 ˇ 4. Marjan Mernik, Mitja Leniˇc, Enis Avdiˇcauˇsevi´c, and Viljem Zumer. Multiple Attribute Grammar Inheritance. Informatica, 24(3):319–328, September 2000. 1 ˇ 5. Marjan Mernik, Mitja Leniˇc, Enis Avdiˇcauˇsevi´c, and Viljem Zumer. Compiler/interpreter generator system LISA. In IEEE CD ROM Proceedings of 33rd Hawaii International Conference on System Sciences, 2000. 1 ˇ 6. Marjan Mernik, Uroˇs Novak, Enis Avdiˇcauˇsevi´c, Mitja Leniˇc, and Viljem Zumer. Design and implementation of simple object description language. In Proceedings of 16th ACM Symposium on applied computing, pages 203–210, 2001. 3 ˇ 7. Marjan Mernik and Viljem Zumer. Incremental language design. IEE Proceedings Software, 145(2-3):85–91, 1998. 3 ˇ 8. Viljem Zumer, Nikolaj Korbar, and Marjan Mernik. Automatic implementation of programming languages using object-oriented approach. Journal of Systems Architecture, 43(1-5):203–210, 1997. 1
Building an Interpreter with Vmgen M. Anton Ertl1 and David Gregg2 1
Institut f¨ ur Computersprachen, Technische Universit¨ at Wien Argentinierstraße 8, A-1040 Wien, Austria
[email protected] 2 Trinity College, Dublin
Abstract. Vmgen automates many of the tasks of writing the virtual machine part of an interpreter, resulting in less coding, debugging and maintenance effort. This paper gives some quantitative data about the source code and generated code for a vmgen-based interpreter, and gives some examples demonstrating the simplicity of using vmgen.
1
Introduction
Interpreters are a popular approach for implementing programming languages, because only interpreters offer all of the following benefits: ease of implementation, portability, and a fast edit-compile-run-cycle. The interpreter generator vmgen1 automates many of the tasks in writing the virtual machine (VM) part of an interpretive system; it takes a simple VM instruction description file and generates code for: executing and tracing VM instructions, generating VM code, disassembling VM code, combining VM instructions into superinstructions, and profiling VM instruction sequences to find superinstructions. Vmgen has special support for stack-based VMs, but most of its features are also useful for register-based VMs. Vmgen supports a number of high-performance techniques and optimizations. The resulting interpreters tend to be faster than other interpreters for the same language. This paper presents an example of vmgen usage. A detailed discussion of the inner workings of vmgen and performance data can be found elsewhere [1].
2
Example Overview
The running example in this paper is the example provided with the vmgen package: an interpretive system for a tiny Modula-2-style language that uses a JVM-style virtual machine. The language supports integer variables and expressions, assignments, if- and while-structures, function definitions and calls. Our example interpreter consists of two conceptual parts: the front-end parses the source code and generates VM code; the VM interpreter executes the VM code. 1
Vmgen is available at http://www.complang.tuwien.ac.at/anton/vmgen/.
R. N. Horspool (Ed.): CC 2002, LNCS 2304, pp. 5–8, 2002. c Springer-Verlag Berlin Heidelberg 2002
6
M. Anton Ertl and David Gregg
Name Lines Description Makefile 67 mini-inst.vmg 139 VM instruction descriptions mini.h 72 common declarations mini.l 42 front-end scanner mini.y 139 front-end (parser, VM code generator) support.c 220 symbol tables, main() peephole-blacklist 3 VM instructions that must not be combined disasm.c 36 template: VM disassembler engine.c 186 template: VM interpreter peephole.c 101 template: combining VM instructions profile.c 160 template: VM instruction sequence profiling stat.awk 13 template: aggregate profile information seq2rule.awk 8 template: define superinstructions 504 template files total 682 specific files total 1186 total
Fig. 1. Source files in the example interpreter
Figure 1 shows quantitative data on the source code of our example. Note that the numbers include comments, which are sometimes relatively extensive (in particular, more than half of the lines in mini-inst.vmg are comments or empty). Some of the files are marked as templates; in a typical vmgen application they will be copied from the example and used with few changes, so these files cost very little. The other files contain code that will typically be written specifically for each application. Among the specific files, mini-inst.vmg contains all of the VM description; in addition, there are VM-related declarations in mini.h, calls to VM code generation functions in mini.y, and calls to the VM interpreter, disassembler, and profiler in support.c. Vmgen generates 936 lines in six files from mini-inst.vmg (see Fig. 2). The expansion factor from the source file indicates that vmgen saves a lot of work in coding, maintaining and debugging the VM interpreter. In addition to the reduced line count there is another reason why vmgen reduces the number of bugs: a new VM instruction just needs to be inserted in one place in mini-inst.vmg (and code for generating it should be added to the front end), whereas in a manually coded VM interpreter a new instruction needs code in several places. The various generated files correspond mostly directly to template files, with the template files containing wrapper code that works for all VMs, and the generated files containing code or tables specific to the VM at hand.
Building an Interpreter with Vmgen
7
Name Lines Description mini-disasm.i 103 VM disassembler mini-gen.i 84 VM code generation mini-labels.i 19 VM instruction codes mini-peephole.i 0 VM instruction combining mini-profile.i 95 VM instruction sequence profiling mini-vm.i 635 VM instruction execution 936 total
Fig. 2. Vmgen-generated files in the example interpreter
3
Simple VM Instructions
A typical vmgen instruction specification looks like this: sub ( i1 i2 -- i ) i = i1-i2; The first line gives the name of the VM instruction (sub) and its stack effect: it takes two integers (i1 and i2) from the stack and pushes one integer (i) on the stack. The next line contains C code that accesses the stack items as variables. Loading i1 and i2 from and storing i to the stack, and instruction dispatch are managed automatically by vmgen. Another example: lit ( #i -- i ) The lit instruction takes the immediate argument i from the instruction stream (indicated by the # prefix) and pushes it on the stack. No user-supplied C code is necessary for lit.
4
VM Code Generation
These VM instructions are generated by the following rules in mini.y: expr: term ’-’ term { gen_sub(&vmcodep); } term: NUM { gen_lit(&vmcodep, $1); } The code generation functions gen sub and gen lit are generated automatically by vmgen; gen lit has a second argument that specifies the immediate argument of lit (in this example, the number being compiled by the front end). Parsing and generating code for all subexpressions, then generating the code for the expression naturally leads to postfix code for a stack machine. This is one of the reasons why stack-based VMs are very popular in interpreters. The programmer just has to ensure that all rules for term and expr produce code that leaves exactly one value on the stack.
8
M. Anton Ertl and David Gregg
The power of yacc and its actions is sufficient for our example, but for implementing a more complex language the user will probably choose a more sophisticated tool or build a tree and manually code tree traversals. In both cases, generating code in a post-order traversal of the expression parse tree is easy.
5
Superinstructions
In addition to simple instructions, you can define superinstructions as a combination of a sequence of simple instructions: lit_sub = lit sub This defines a new VM instruction lit sub that behaves in the same way as the sequence lit sub, but is faster. After adding this instruction to mini-inst.vmg and rebuilding the interpreter, this superinstruction is generated automatically whenever a call to gen lit is followed by a call to gen sub. But you need not even define the superinstructions yourself, you can generate them automatically from a profile of executed VM instruction sequences: You can compile the VM interpreter with profiling enabled, and run some programs representing your workload. The resulting profile lists the number of dynamic executions for each static occurence of a sequence, e.g., 18454929 9227464
lit sub ... lit sub
This indicates that the sequence lit sub occured in two places, for a total of 27682393 dynamic executions. These data can be aggregated with the stat.awk script, then the user can choose the most promising superinstructions (typically with another small awk or perl script), and finally transform the selected sequences into the superinstruction rule syntax with seq2rule.awk. The original intent of the superinstruction features was to improve the runtime performance of the interpreter (and it achieves this goal), but we also noticed that it makes interpreter construction easier: In some places in an interpretive system, we can generate a sequence of existing instructions or define a new instruction and generate that; in a manually written interpreter, the latter approach yields a faster interpreter, but requires more work. Using vmgen, you can just take the first approach, and let the sequence be optimized into a superinstruction if it occurs frequently; in this way, you get the best of both approaches: little effort and run-time performance.
References 1. M. Anton Ertl, David Gregg, Andreas Krall, and Bernd Paysan. vmgen — a generator of efficient virtual machine interpreters. Software—Practice and Experience, 2002. Accepted for publication. 5
Compiler Construction Using LOTOS NT Hubert Garavel, Fr´ed´eric Lang, and Radu Mateescu Inria Rhˆ one-Alpes – Vasy 655, avenue de l’Europe, 38330 Montbonnot, France {Hubert.Garavel,Frederic.Lang,Radu.Mateescu}@inria.fr
1
Introduction
Much academic and industrial effort has been invested in compiler construction. Numerous tools and environments1 have been developed to improve compiler quality while reducing implementation and maintenance costs. In the domain of computer-aided verification, most tools involve compilation and/or translation steps. This is the case with the tools developed by the Vasy team of Inria Rhˆ one-Alpes, for instance the Cadp2 [5] tools for analysis of protocols and distributed systems. As regards the lexical and syntax analysis, all Cadp tools are built using Syntax [3], a compiler generator that offers advanced error recovery features. As regards the description, construction, and traversal of abstract syntax trees (Asts), three approaches have been used successively: – In the Caesar [8] compiler for Lotos [10], Asts are programmed in C. This low-level approach leads to slow development as one has to deal explicitly with pointers and space management to encode and explore Asts. – In the Caesar.Adt [6] and Xtl [13] compilers, Asts are described and handled using Lotos abstract data types, which are then translated into C using the Caesar.Adt compiler itself (bootstrap); yet, for convenience and efficiency, certain imperative processings are directly programmed in C. This approach reduces the drawbacks of using C exclusively, but suffers from limitations inherent to the algebraic specification style (lack of local variables, of sequential composition, etc.). – For the Traian and Svl 1.0 compilers, and for the Evaluator 3.0 [14] model-checker, the Fnc-23 [12] compiler generator based on attribute grammars was used. Fnc-2 allows to declare attribute calculations for each Ast node and evaluates the attributes automatically, according to their dependencies. Although we have been able to suggest many improvements incorporated to Fnc-2, it turned out that, for input languages with large grammars, Fnc-2 has practical limitations: development and debugging are complex, and the generated compilers have large object files and exhibit average performances (slow compilation, large memory footprint due to the creation of multiple Asts and the absence of garbage collection). Therefore, the Vasy team switched to a new technology in order to develop its most recent verification tools. 1 2 3
An extensive catalog can be found at http://catalog.compilertools.net http://www.inrialpes.fr/vasy/cadp http://www.inrialpes.fr/vasy/fnc2
R. N. Horspool (Ed.): CC 2002, LNCS 2304, pp. 9–13, 2002. c Springer-Verlag Berlin Heidelberg 2002
10
2
Hubert Garavel et al.
Using LOTOS NT for Compiler Construction
E-Lotos (Enhanced Lotos) [11] is a new Iso standard for the specification of protocols and distributed systems. Lotos NT [9,16] is a simplified variant of E-Lotos targeting at efficient implementation. It combines the strong theoretical foundations of process algebras with language features suitable for a wide industrial use. The data part of Lotos NT significantly improves over the previous Lotos standard [10]: equational programming is replaced with a language similar to first-order Ml extended with imperative features (assignments, loops, etc.). A compiler for Lotos NT, named Traian,4 translates the data part of Lotos NT specifications into C. Used in conjunction with a parser generator such as Lex/Yacc or Syntax, Traian is suitable to compiler construction: – Lotos NT allows a straightforward description of Asts: each non-terminal symbol of the grammar is encoded by a data type having a constructor for each grammar rule associated to the symbol. Traversals of Asts for computing attributes are defined by recursive functions using “case” statements and pattern-matching. – Traian generates automatically “printer” functions for each Lotos NT data type, which enables to inspect Asts and facilitates the debugging of semantic passes. – Traian also allows to include in a Lotos NT specification external data types and functions implemented in C, enabling an easy interfacing of Lotos NT specifications with hand-written C modules as well as C code generated by Lex/Yacc or Syntax.
3
Applications
Since 1999, Lotos NT has been used to develop three significant compilers. For each compiler, the lexer and parser are built using Syntax and the Asts using Lotos NT. Type-checking, program transformation, and code generation are also implemented in Lotos NT. Some hand-written C code is added either for routine tasks (e.g., parsing options) or for some specialized algorithms (e.g., model-checking): – The Svl 2.0 [7] compiler transforms high-level verification scripts into Bourne shell scripts (see Figure 1). – The Evaluator 4.0 model-checker transforms a temporal logic formula into a boolean equation system solver written in C; the solver is then compiled and executed, taking as input a labelled transition system and producing a diagnostic (see Figure 2). – The Ntif tool suite deals with a high-level language for symbolic transition systems; it includes a front-end, the Nt2if back-end generating a lower-level format, and the Nt2dot back-end producing a graph format visualizable by At&t’s GraphViz package. 4
http://www.inrialpes.fr/vasy/traian
Compiler Construction Using LOTOS NT
INPUT
SVL Program
Syntax Analysis & AST construction (SYNTAX)
Syntax error
LOTOS NT Term
Type Checking (LOTOS NT)
Type error
LOTOS NT Term
Expansion of Meta-Operations (LOTOS NT)
Code Generation (LOTOS NT)
LOTOS NT Term
Input Files
OUTPUT
Shell Interpreter
Bourne Shell Script
11
Output Files
INPUT
Fig. 1. Architecture of the Svl 2.0 compiler
Temporal Logic Formula
Labelled Transition System
OUTPUT
Model Checker
Syntax Analysis & AST construction (SYNTAX)
LOTOS NT Term
Syntax error Type error
Type Checking (LOTOS NT)
LOTOS NT Term
C Compiler
BES Solver (C)
Transl. to Boolean Equation Systems (LOTOS NT)
Diagnostic File
Fig. 2. Architecture of the Evaluator 4.0 model-checker The table below summarizes the size (in lines of code) of each compiler. Syntax Lotos NT C Shell Total Generated C Svl 2.0 1,250 2,940 370 2,170 6,730 12,400 Evaluator 4.0 3,600 7,500 3,900 — 15,000 37,000 Ntif 1,620 3,620 1,200 — 6,440 20,644
4
Related Work and Conclusions
Alternative approaches exist based upon declarative representations, such as attributed grammars (Fnc-2 [12], SmartTools [1]), logic programming (Ale [4], Centaur [2]), or term rewriting (Txl5 , Kimwitu [18], Asf+Sdf [17]). In these 5
http://www.thetxlcompany.com
12
Hubert Garavel et al.
approaches, Asts are implicit (not directly visible to the programmer) and it is not necessary to specify the order of attribute evaluation, which is inferred from the dependencies. On the contrary, our approach requires the explicit Ast specification and attribute computation ordering. Practically, this is not too restrictive, since the user is usually aware of these details. Lotos NT is an hybrid between imperative and functional languages. Unlike the object-oriented approach (e.g., JavaCC6 ), in which Asts are defined using classes, and visitors are implemented using methods, the Lotos NT code for computing a given attribute does not need to be split into several classes, but can be clearly centralized in a single function containing a “case” statement. Compared to lower-level imperative languages such as C, Lotos NT avoids tedious and error-prone explicit pointer manipulation. Compared to functional languages such as Haskell or Caml7 (for which the Happy8 and CamlYacc parser generators are available), Lotos NT does not allow higher-order functions nor polymorphism. In practice, we believe that these missing features are not essential for compiler construction; instead, Lotos NT provides useful mechanisms such as strong typing, function overloading, pattern-matching, and sequential composition. Lotos NT external C types and functions make input/output operations simpler than Haskell/Happy, in which one must be acquainted with the notion of monads. Contrary to functional languages specifically dedicated to compiler construction such as Puma9 and Gentle [15], Lotos NT is a general-purpose language, applicable to a wider range of problems. The Lotos NT technology can be compared with other hybrid approaches such as the App10 and Memphis11 preprocessors, which extend C/C++ with abstract data types and pattern-matching. Yet, these preprocessors lack the static analysis checks supported by Lotos NT and Traian (strong typing, detection of uninitialized variables, exhaustiveness of “case” statements, etc.), which significantly facilitate the programming activity. Our experience in using Lotos NT for developing three compilers demonstrated the efficiency and robustness of this pragmatic approach. Since 1998, the Traian compiler is available on several platforms (Windows, Linux, Solaris) and can be downloaded on the Internet. The three Traian-based compilers are or will be available soon: Svl 2.0 is distributed within Cadp 2001 “Ottawa”; Evaluator 4.0 and Ntif will be released in future versions of Cadp. Ntif is already used in a test generation platform for smart cards in an industrial project with Schlumberger. 6 7 8 9 10 11
http://www.webgain.com/products/java_cc http://caml.inria.fr http://www.haskell.org/happy Puma belongs to the Cocktail toolbox (http://www.first.gmd.de/cocktail) http://www.primenet.com/~georgen/app.html http://memphis.compilertools.net
Compiler Construction Using LOTOS NT
13
References 1. I. Attali, C. Courbis, P. Degenne, A. Fau, D. Parigot, and C. Pasquier. SmartTools: A Generator of Interactive Environments Tools. In Proc. of CC ’2001, volume 2027 of LNCS, 2001. 11 2. P. Borras, D. Cl´ement, Th. Despeyroux, J. Incerpi, G. Kahn, B. Lang, and V. Pascual. Centaur: the system. In Proc. of SIGSOFT’88, 3rd Symposium on Software Development Environments (SDE3), 1988. 11 3. P. Boullier and P. Deschamp. Le syst`eme SYNTAX : Manuel d’utilisation et de mise en œuvre sous Unix. http://www-rocq.inria.fr/oscar/www/syntax, 1997. 9 4. B. Carpenter. The Logic of Typed Feature Structures. Cambridge Tracts in Theoretical Computer Science, 32, 1992. 11 5. J.-C. Fernandez, H. Garavel, A. Kerbrat, R. Mateescu, L. Mounier, and M. Sighireanu. CADP (CÆSAR/ALDEBARAN Development Package): A Protocol Validation and Verification Toolbox. In Proc. of CAV ’96, volume 1102 of LNCS, 1996. 9 6. H. Garavel. Compilation of LOTOS Abstract Data Types. In Proc. of FORTE’89. North-Holland, 1989. 9 7. H. Garavel and F. Lang. SVL: A Scripting Language for Compositional Verification. In Proc. of FORTE’2001. Kluwer, 2001. INRIA Research Report RR-4223. 10 8. H. Garavel and J. Sifakis. Compilation and Verification of LOTOS Specifications. In Proc. of PSTV’90. North-Holland, 1990. 9 9. H. Garavel and M. Sighireanu. Towards a Second Generation of Formal Description Techniques – Rationale for the Design of E-LOTOS. In Proc. of FMICS’98, Amsterdam, 1998. CWI. Invited lecture. 10 10. ISO/IEC. LOTOS — A Formal Description Technique Based on the Temporal Ordering of Observational Behaviour. International Standard 8807, 1988. 9, 10 11. ISO/IEC. Enhancements to LOTOS (E-LOTOS). International Standard 15437:2001, 2001. 10 12. M. Jourdan, D. Parigot, C. Juli´e, O. Durin, and C. Le Bellec. Design, Implementation and Evaluation of the FNC-2 Attribute Grammar System. ACM SIGPLAN Notices, 25(6), 1990. 9, 11 13. R. Mateescu and H. Garavel. XTL: A Meta-Language and Tool for Temporal Logic Model-Checking. In Proc. of STTT ’98. BRICS, 1998. 9 14. R. Mateescu and M. Sighireanu. Efficient On-the-Fly Model-Checking for Regular Alternation-Free Mu-Calculus. In Proc. of FMICS’2000, 2000. INRIA Research Report RR-3899. To appear in Science of Computer Programming. 9 15. F. W. Schr¨ oer. The GENTLE Compiler Construction System. R. Oldenbourg Verlag, 1997. 12 16. M. Sighireanu. LOTOS NT User’s Manual (Version 2.1). INRIA projet VASY. ftp://ftp.inrialpes.fr/pub/vasy/traian/manual.ps.Z, November 2000. 10 17. M. G. J. van den Brand, A. van Deursen, J. Heering, H. A. de Jong, M. de Jonge, T. Kuipers, P. Klint, L. Moonen, P. A. Olivier, J. Scheerder, J. J. Vinju, E. Visser, and J. Visser. The ASF+SDF Meta-Environment: A Component-Based Language Development Environment. In Proc. of CC ’2001, volume 2027 of LNCS, 2001. 11 18. P. van Eijk, A. Belinfante, H. Eertink, and H. Alblas. The Term Processor Generator Kimwitu. In Proc. of TACAS ’97, 1997. 11
Data Compression Transformations for Dynamically Allocated Data Structures Youtao Zhang and Rajiv Gupta Dept. of Computer Science, The University of Arizona, Tucson, Arizona 85721
Abstract. We introduce a class of transformations which modify the representation of dynamic data structures used in programs with the objective of compressing their sizes. We have developed the commonprefix and narrow-data transformations that respectively compress a 32 bit address pointer and a 32 bit integer field into 15 bit entities. A pair of fields which have been compressed by the above compression transformations are packed together into a single 32 bit word. The above transformations are designed to apply to data structures that are partially compressible, that is, they compress portions of data structures to which transformations apply and provide a mechanism to handle the data that is not compressible. The accesses to compressed data are efficiently implemented by designing data compression extensions (DCX) to the processor’s instruction set. We have observed average reductions in heap allocated storage of 25% and average reductions in execution time and power consumption of 30%. If DCX support is not provided the reductions in execution times fall from 30% to 12.5%.
1
Introduction
With the proliferation of limited memory computing devices, optimizations that reduce memory requirements are increasing in importance. We introduce a class of transformations which modify the representation of dynamically allocated data structures used in pointer intensive programs with the objective of compressing their sizes. The fields of a node in a dynamic data structure typically consist of both pointer and non-pointer data. Therefore we have developed the common-prefix and narrow-data transformations that respectively compress a 32 bit address pointer and a 32 bit integer field into 15 bit entities. A pair of fields which have been compressed can be packed into a single 32 bit word. As a consequence of compression, the memory footprint of the data structures is significantly reduced leading to significant savings in heap allocated storage requirements which is quite important for memory intensive applications. The reduction in memory footprint can also lead to significantly reduced execution times due to a reduction in data cache misses that occur in the transformed program.
Supported by DARPA PAC/C Award. F29601-00-1-0183 and NSF grants CCR0105355, CCR-0096122, EIA-9806525, and EIA-0080123 to the Univ. of Arizona.
R. N. Horspool (Ed.): CC 2002, LNCS 2304, pp. 14-28, 2002. c Springer-Verlag Berlin Heidelberg 2002
Data Compression Transformations for Dynamically Allocated Data Structures
15
An important feature of our transformations is that they have been designed to apply to data structures that are partially compressible. In other words, they compress portions of data structures to which transformations apply and provide a mechanism to handle the data that is not compressible. Initially data storage for a compressed data structure is allocated assuming that it is fully compressible. However, at runtime, when uncompressible data is encountered, additional storage is allocated to handle such data. Our experience with applications from Olden test suite demonstrates that this is a highly important feature because all the data structures that we examined in our experimentation were highly compressible, but none were fully compressible. For efficiently accessing data in compressed form we propose data compression extensions (DCX) to a RISC-style ISA which consist of six simple instructions. These instructions perform two types of operations. First since we must handle partially compressible data structures, whenever a field that has been compressed is updated, we must check to see if the new value to be stored in that field is indeed compressible. Second when we need to make use of a compressed value in a computation, we must perform an extract and expand operation to obtain the original 32 bit representation of the value. We have implemented our techniques and evaluated them. The DCX instructions have been incorporated into the MIPS like instruction set used by the simplescalar simulator. The compression transformations have been incorporated in the gcc compiler. We have also addressed other important implementation issues including the selection of fields for compression and packing. Our experiments with six benchmarks from the Olden test suite demonstrate an average space savings of 25% in heap allocated storage and average reductions of 30% in execution times and power consumption. The net reduction in execution times is attributable to reduced miss rates for L1 data cache and L2 unified cache and the availability of DCX instructions.
2
Data Compression Transformations
As mentioned earlier, we have developed two compression transformations: one to handle pointer data and the other to handle narrow width non-pointer data. We illustrate the transformations by using an example of the dynamically allocated link list data structure shown below – the next and value fields are compressed to illustrate the compression of both pointer and non-pointer data. The compressed fields are packed together to form a single 32 bit field value next. Original Structure: struct list node { · · ·; int value; struct list node *next; } *t;
Transformed Structure: struct list node { · · ·; int value next; } *t;
Common-Prefix transformation for pointer data. The pointer contained in the next field of the link list can be compressed under certain conditions. In particular, consider the addresses corresponding to an instance of list node (addr1)
16
Youtao Zhang and Rajiv Gupta
and the next field in that node (addr2). If the two addresses share a common 17 bit prefix because they are located fairly close in memory, we classify the next pointer as compressible. In this case we eliminate the common prefix from address addr2 which is stored in the next pointer field. The lower order 15 bits from addr2 represent the representation of the pointer in compressed form. The 32 bit representation of a next field can be reconstructed when required by obtaining the prefix from the pointer to the list node instance to which the next field belongs. Narrow data transformation for non-pointer data. Now let us consider the compression of the narrow width integer value in the value field. If the 18 higher order bits of an array element are identical, that is, they are either all 0’s or all 1’s, it is classified as compressible. The 17 higher order bits are discarded and leaving a 15 bit entity. Since the 17 bits discarded are identical to the most significant order bit of the 15 bit entity, the 32 bit representation can be easily derived when needed by replicating the most significant bit. Packing together compressed fields. The value and next fields of a node belonging to an instance of list node can be packed together into a single 32 bit word as they are simply 15 bit entities in their compressed form. Together they are stored in value next field of the transformed structure. The 32 bits of value next are divided into two half words. Each compressed field is stored in the lower order 15 bits of the corresponding half word. According to the above strategy, bits 15 and 31 are not used by the compressed fields. Next we describe the handling of uncompressible data in partially compressible data structures. The implementation of partially compressible data structures require an additional bit for encoding information. This is why we compress fields down to 15 bit entities and not into 16 bit entities. Partial compressibility. Our basic approach is to allocate only enough storage to accommodate a compressed node when a new node in the data structure is created. Later, as the pointer fields are assigned values, we check to see if the fields are compressible. If they are, they can be accommodated in the allocated space; otherwise additional storage is allocated to hold the fields in uncompressed form. The previously allocated location is now used to hold a pointer to this additional storage. Therefore for accessing uncompressible fields we have to go through an extra step of indirection. If the uncompressible data stored in the fields is modified, it is possible that the fields may now become compressible. However, we do not carry out such checks and instead we leave the fields in such cases in uncompressed form. This is because exploitation of such compression opportunities can lead to repeated allocation and deallocation of extra locations if data values repeatedly keep oscillating between compressible and uncompressible kind. To avoid repeated allocation and deallocation of extra locations we simplify our approach so that once a field is assigned an uncompressible value, from then onwards, the data in the field is always maintained in uncompressed form.
Data Compression Transformations for Dynamically Allocated Data Structures
17
We use the most significant bit (bit 31) in the word to indicate whether or not the data stored in the word is compressed or not. This is possible because in the MIPS base system that we use, the most significant bit for all heap addresses is always 0. It contains a 0 to indicate that the word contains compressed values. If it contains a 1, it means that one or both of values were not compressible and instead the word contains a pointer to an extra pair of dynamically allocated locations which contain the values of the two fields in uncompressed form. While bit 31 is used to encode extra information, bit 15 is never used for any purpose. Original: Set "value" field and Create "next" link addr0
addr0
t
t value next
addr1
value ( = v1 ) next
nil
Transformed(case 1) : both "next" and "value" fields are compressible addr0
addr0
t
t 0
nv
(v1)
0
nv
(v1)
nil
addr11 Transformed(case 2) : "value" is compressible and "next" is not addr0
addr0
t
t 0
nv
(v1)
1
nv
nil
v1
addr11
v1
addr11
Transformed(case 3) : "value" is not compressible addr0
addr0
t
t 1
nv
1
v1
nv
nil
Fig. 1. Dealing with uncompressible data.
In Fig. 1 we illustrate the above method using an example in which an instance of list node is allocated and then the value and next fields are set up one at a time. As we can see first storage is allocated to accommodate the two fields in compressed form. As soon as the first uncompressible field is encountered additional storage is allocated to hold the two fields in uncompressed form. Under this scheme there are three possibilities which are illustrated in Fig. 1. In the first case both fields are found to be compressible and therefore no extra locations are allocated. In the second case the value field, which is accessed first, is compressible but the next field is not. Thus, initially value field is stored in compressed form but later when next field is found to be compressible, extra locations are allocated and both fields are store in uncompressed form. Finally in the third case the value field is not compressible and therefore extra locations are allocated right away and none of the two fields are ever stored in compressed form.
18
Youtao Zhang and Rajiv Gupta
3
Instruction Set Support
Compression reduces the amount of heap allocated storage used by the program which typically improves the data cache behavior. Also if both the fields need to be read in tandem, a single load is enough to read both the fields. However, the manipulation of the fields also creates additional overhead. To minimize this overhead we have design new RISC-style instructions. We have designed three simple instructions each for pointer and non-pointer data respectively that efficiently implement common-prefix and narrow-data transformations. The semantics of the these instructions are summarized in Fig. 2. These instructions are RISC-style instructions with complexity comparable to existing branch and integer ALU instructions. Let us discuss these instructions in greater detail. Checking compressibility. Since we would like to handle partially compressible data, before we actually compress a data item at runtime, we must first check whether the data item is compressible. Therefore the first instruction type we introduce allows efficient checking of data compressibility. We have provided the two new instructions that are described below. The first checks the compressibility of pointer data and the second does the same for non-pointer data. bneh17 R1, R2, L1 – is used to check if the higher order 17 bits of R1 and R2 are the same. If they are the same, the execution continues and the field held in R2 can be compressed; otherwise the branch is taken to a point where we handle the situation, by allocating additional storage, in which the address in R2 is not compressible. The instruction also handles the case where R2 contains a nil pointer which is represented by the value 0 both in compressed and uncompressed forms. Since 0 represents a nil pointer, the lower order 15 bits of an allocated address should never be all zeroes - to correctly handle this situation we have modified our malloc routine so that it never allocates storage locations with such addresses. bneh18 R1, L1 – is used to check if the higher order 18 bits of R1 are identical (i.e., all 0’s or all 1’s). If they are the same, the execution continues and the value held in R1 is compressed; otherwise the value in R1 is not compressible and the branch is taken to a point where we place code to handle this situation by allocating additional storage. Extract-and-expand. If a pointer is stored in compressed form, before it can be derefrenced, we must first reconstruct its 32-bit representation. We do the same for compressed non-pointer data before its use. Therefore the second instruction type that we introduce carries out extract-and-expand operations. There are four new instructions that we describe below. The first two instructions are used to extract-and-expand compressed pointer fields from lower and upper halves of a 32-bit word respectively. The next two instructions do the same for non-poniter data. xtrhl R1, R2, R3 – extracts the compressed pointer field stored in lower order bits (0 through 14) of register R3 and appends it to the common-prefix
Data Compression Transformations for Dynamically Allocated Data Structures
19
contained in higher order bits (15 through 31) of R2 to construct the uncompressed pointer which is then made available in R1. We also handle the case when R3 contains a nil pointer. If the compressed field is a nil pointer, R1 is set to nil.
BNEH18 R1,L1
BNEH17 R1,R2,L1 if ( R2 != 0 ) && ( R131..15 != R231..15 ) goto L1 31
...
15
14 ...
if ( R131..14 != 0 ) && ( R131..14 != 0x3ff ) goto L1
0
31
R1
...
14
13 ...
0
R1
R2 XTRHL
XTRL
R1,R2,R3
if ( R314..0 != 0 ) /* Non-NULL case */ R1 = R231..15 R314..0 else R1 = 0 31 ... 15
14 ... 0
31
R2
R2 31
R3
30 ... 16
15
0
13 ... 0
- x
31 ... 15
31 30 29 ... 16
R2 31
30 ... 16
R1,R2
if ( R230 == 1 ) R1 = 0x1ffff R230..16 else R1 = R230..16
14 ... 0
R2 0
xxxxxxxxxxxxxxxx
XTRH
R1,R2,R3
if ( R330..16 != 0 ) /* Non-NULL case */ R1 = R231..15 R330..16 else R1 = 0
R1
15 14
14 ... 0
R1
R3
30 ... 16
0
-
R1 XTRHH
R1,R2
if ( R214 == 1 ) R1 = 0x1ffff R214..0 else R1 = R214..0
15
0 x
15
14 ... 0
-
14 ... 0
-
R1
xxxxxxxxxxxxxxxx
Fig. 2. DCX instructions.
xtrhh R1, R2, R3 – extracts the compressed pointer field stored in the higher order bits (16 through 30) of register R3 and appends it to the commonprefix contained in higher order bits (15 through 31) of R2 to construct the uncompressed pointer which is then made available in R1. If the compressed field is a nil pointer, R1 is set to nil. The instructions xtrhl and xtrhh can also be used to compress two fields together. However, they are not essential for this purpose because typically there are existing instructions which can perform this operation. In the MIPS like instruction set we used in this work this was indeed the case. xtrl R1, R2 – extracts the field stored in lower half of the R2, expands it, and then stores the resulting 32 bit value in R1. xtrh R1, R2 – extracts the field stored in the higher order bits of R2, exapands it, and then stores the resulting 32 bit value in R1.
20
Youtao Zhang and Rajiv Gupta
Next we give a simple example to illustrate the use of the above instructions. Let us assume that an integer field t → value and a pointer field t → next are compressed together into a single field t → value next. In Fig. 3a we show how compressibility checks are used prior to appropriately storing newvalue and newnext values in to the compressed fields. In Fig. 3b we illustrate the extract and expand instructions by extracting the compressed values stored in t → value next. ; $16 : &t− > value next ; $18 : newvalue ; $19 : newnext ; ; branch if newvalue is not compressible bneh18 $18, $L1 ; branch if newnext is not compressible bneh17 $16, $19, $L1 ; store compressed data in t− > value next ori $19, $19, 0x7fff swr $18, 0($16) swr $19, 2($16) j $L2 $L1: ; allocate extra locations and store pointer ; to extra locations in t− > value next ; store uncompressed data in extra locations ··· $L2: · · · (a) Illustration of compressibility checks. ; $16: &(t− > value next) ; $17: uncompressed integer t− > value ; $18: uncompressed pointer t− > next ; ; load contents of t− > value next lw $3,0($16) ; branch if $3 is a pointer to extra locations bltz $3, $L1 ; extract and expand t− > value xtrl $17, $3 ; extract and expand t− > next xtrhh$18, $16, $3 j $L2 $L1: ; load values from extra locations ··· $L2: · · · (b) Illustration of extract and expand instructions. Fig. 3. An example.
Data Compression Transformations for Dynamically Allocated Data Structures
4
21
Compiler Support
Object layout transformations can only be applied to a C program if the user does not access the fields through explicit address arithmetic and also does not typecast the objects of the transformed type into objects of another type. Like prior work by Truong et al. [14] on field reorganization and instance interleaving, we assume that the programmer has given us the go ahead to freely transform the data structures when it is apprpriate to do so. From this step onwards the rest of process is carried out automatically by the compiler. In the remainder of this section we describe key aspects of the the compiler support required for effective data compression. Identifying fields for compression and packing. Our observation is that most pointer fields can be compressed quite effectively using the common-prefix transformation. Integer fields to which narrow-data transformation can be applied can be identified either based upon knowledge about the application or using value profiling. The most critical issue is that of pairing compressed fields for packing into a single word. For this purpose we must first categorize the fields as hot fields and cold fields. It is useful to pack two hot fields together if they are typically accessed in tandem. This is because in this situation a single load can be shared while reading the two values. It is also useful to compress any two cold fields even if they are not accessed in tandem. This is because even though they cannot share the same load, they are not accessed frequently. In all other situations it is not as useful to pack data together because even though space savings will be obtained, execution time will be adversely affected. We used basic block frequency counts to identify pairs of fields belonging to the above categories and then applied compression transformations to them. ccmalloc vs malloc. We make use of ccmalloc [6], a modified version of malloc, for carrying out storage allocation. This form of storage allocation was developed by Chilimbi et al. [6] and as described earlier it improves the locality of dynamic data structures by allocating the linked nodes of the data structure as close to each other as possible in the heap. As a consequence, this technique increases the likelihood that the pointer fields in a given node will be compressible. Therefore it makes sense to use ccmalloc in order to exploit the synergy between ccmalloc and data compression. Register pressure. Another issue that we consider in our implementation is that of potential increase in register pressure. The code executed when the pointer fields are found to be uncompressible is substantial and therefore it can increase register pressure significantly causing a loss in performance. However, we know that this code is executed very infrequently since very few fields are uncompressible. Therefore, in this piece of code we first free registers by saving values and then after executing the code the values are restored in registers. In other words, the increase in register pressure does not have an adverse effect on frequently executed code.
22
Youtao Zhang and Rajiv Gupta
Instruction cache behavior and code size. The additional instructions generated for implementing compression can lead to an increase in code size which can further impact the instruction cache behavior. It is important to note however that a large part of the code size increase is due to the handling of the infrequent case in which the data is found not to be compressible. In order to minimize the impact on the code size we can share the code for handling the above infrequent case across all the updates corresponding to a given data field. To minimize the impact of the performance on the instruction cache, we can employ a code layout strategy which places the above infrequently executed code elsewhere and create branches to it and back so that the instruction cache behavior for more frequently executed code is minimally affected. Our implementation currently does not support the above techniques and therefore we observed code size increase and degraded instruction cache behavior in our experiments. Code generation. The remainder of the code generation details for implementing data compression are in most part quite straightforward. Once the fields have been selected for compression and packing together, whenever a use of a value of any of the fields is encountered, the load is followed by an extract-and expand instruction. If the value of any of compressed fields is to be updated, the compressibility check is performed before storing the value. When two hot fields that are packed together are to be read/updated, initially we generate separate loads/stores for them. Later in a separate pass we eliminate the later of the two loads/stores whenever possible.
5
Performance Evaluation
Experimental setup. We have implemented the techniques described to evaluate their performance. The transformations have been implemented as part of the gcc compiler and the DCX instructions have been incorporated in the MIPS like instruction set of the superscalar processor simulated by simplescalar [3]. The evaluation is based upon six benchmarks taken from the Olden test suite [5] (see Fig. 4a) which contains pointer intensive programs that make extensive use of dynamically allocated data structures. In order to study the impact of memory performance we varied the input sizes of the programs and also varied the L2 cache latency. The cache organization of simplescalar is shown in Fig. 4b. There are first level separate instruction and data caches (I-cache and D-cache). The lower level cache is a unified-cache for instructions and data. The L1 cache used was a 16K direct mapped cache with 9 cycle miss latency while the unified L2 cache is 256K with 100/200/400 cycle miss latencies. Our experiments are for an out-of-order issue superscalar with issue width of 4 instructions and the Bimod branch predictor. Impact on storage needs. The transformations applied and their impact on the node sizes is shown in Fig. 5a. In the first four benchmarks (treeadd, bisort, tsp, and perimeter), node sizes are reduced by storing pairs of compressed pointers in a single word. In the health benchmark a pair of small values are
Data Compression Transformations for Dynamically Allocated Data Structures
Program treeadd
Application Recursive sum of values in a B-tree bisort Bitonic Sorting tsp Traveling salesman problem perimeter Perimeters of regions in images health Columbian health care simulation mst Minimum Spanning tree of a graph (a) Benchmarks.
Parameter Issue Width I cache I cache miss latency L1 data cache L1 data cache miss latency L2 unified cache Memory latency (L2 cache miss latency)
23
Value 4 issue, out of order 16K direct mapped 9 cycles 16K direct mapped 9 cycles 256K 2-way Configuration 1/2/3 = 100/200/400 cycles
(b) Machine configurations. Fig. 4. Experimental setup.
compressed together and stored in a single word. Finally, in the mst benchmark a compressed pointer and a compressed small value are stored together in a single word. The changes in node sizes range from 25% to 33% for five of the benchmarks. Only in case of tsp is the reduction smaller – just over 10%. We measured the runtime savings in heap allocated storage for small and large program inputs. The results are given in Fig. 5b. The average savings are nearly 25% while they range from 10% to 33% across different benchmarks. Even more importantly these savings represent significant levels of heap storage – typically in megabytes. For example, the 33% storage savings for treeadd represents 4.2 Mbytes and 17 Mbytes of heap storage savings for small and large program inputs respectively. It should also be noted that such savings cannot be obtained by other locality improving techniques described earlier [14, 15, 6]. From the results in Fig. 5b we make another very important observation. The extra locations allocated when non-compressible data is encountered is non-zero for all of the benchmarks. In other words we observe that for none of the data structures to which our compression transformations were applied, were all of the instances of the data encountered at runtime actually compressible. A small amount of additional locations were allocated to hold a small number of uncompressible pointers and small values in each case. Therefore the generality of our transformation which allows handling of partially compressible data structures is extremely important. If we had restricted the application of our technique to data fields that are always guaranteed to be compressible, we could not have achieved any compression and therefore no space savings would have resulted. We also measured the increase in code size caused by our transformations (see Fig. 5c). The increase in code size prior to linking is significant while after linking the increase is very small since the user code is small part of the binaries. However, the reason for significant increase in user code is because each time a compressed field is updated, our current implementation generates a new copy of the additional code for handling the case where the data being stored may
24
Youtao Zhang and Rajiv Gupta
not be compressible. In practice it is possible to share this code across multiple updates. Once such sharing has been implemented, we expect that the increase in the size of user code will also be quite small. Program
Transformation Applied
Size Change (bytes) treeadd Com.Prefix/Com.Prefix from 28 to 20 bisort Com.Prefix/Com.Prefix from 12 to 8 tsp Com.Prefix/Com.Prefix from 36 to 32 perimeter Com.Prefix/Com.Prefix from 12 to 8 health NarrowData/NarrowData from 16 to 12 mst Com.Prefix/NarrowData from 16 to 12
Program
Before After Linking Linking treeadd 16.4% 0.04% bisort 40.0% 0.01% tsp 4.9% 0.18% perimeter 21.3% 1.97% health 33.7% 0.23% mst 10.7% 0.06% average 21.1% 0.41%
(a) Reduction in node size.
(c) Code size increase. Storage (bytes)
Program treeadd bisort tsp perimeter health mst average
Original 12582900 786420 5242840 4564364 566872 3414020
Small Input Total (Extra) 8402040 (13440) 549880 (25600) 4200352 (6080) 3265380 (5120) 510272 (320) 2367812 (320)
Savings 33.2 % 30.1 % 19.9 % 28.5 % 10.0 % 30.6 % 25.4 %
Original 50331636 3145716 20971480 20332620 1128240 54550532
Large Input Total (Extra) 33605684 (51260) 2301304 (204160) 16800224 (23040) 14546980 (23680) 1015124 (320) 37781828 (320)
Savings 33.2 % 26.8 % 19.9 % 28.5 % 10.0 % 30.7 % 24.9 %
(b) Reduction in heap storage for small and large inputs. Fig. 5. Impact on storage needs.
Impact on execution times. Based upon the cycle counts provided by the simplescalar simulator we studied the changes in execution times resulting from compression transformations. The impact of L2 latency on execution times was also studied. The results in Fig. 6 are for small inputs. For L2 cache latency of 100 cycles, the reduction in execution times in comparison to the original programs which use malloc range from 3% to 64% while on an average the reduction in execution time is around 30%. The reductions for higher latencies are also similar. We also compared our execution times with versions of the programs that use ccmalloc. Our approach outperforms ccmalloc in five out of the six benchmarks (our version of mst runs slightly slower than the ccmalloc version). On an average we outperform ccmalloc by nearly 10%. Our approach outperforms ccmalloc because once the node sizes are reduced, typically greater number of nodes fit into a single cache line leading to a low number of cache misses. We also pay additional runtime overhead in form of extra instructions needed to carry out compression and extraction of compressed values. However, this additional
Data Compression Transformations for Dynamically Allocated Data Structures
25
execution time is more than offset by the time savings resulting from reduced cache misses; thus leading to overall reduction in execution time. On an average, compression reduces the execution times by 10%, 15%, and 20% over ccmalloc for L2 cache latencies of 100, 200, and 400 cycles respectively. Therefore we observe that as the latency of L2 cache is increased, compression outperforms ccmalloc by a greater extent. In summary our approach provides large storage savings and significant execution time reductions over ccmalloc. Comp./Orig.*100 (Latency=100 cycles) Comp./Orig.*100 (Latency=200 cycles) Comp./Orig.*100 (Latency=400 cycles) Comp./ccmalloc*100 (Latency=100 cycles) Comp./ccmalloc*100 (Latency=200 cycles) Comp./ccmalloc*100 (Latency=400 cycles)
120
percentage comparison
100
80
60
40
20
0
add
tree
t
r biso
tsp
ter
ime
per
lth
hea
t
ms
e
rag
ave
Fig. 6. Reduction in execution time due to data compression.
We would also like to point out that the use of special DCX instructions was critical in reducing the overhead of compression and extraction. Without DCX instructions the programs would have ran significantly slower. We ran versions of programs which did not use DCX instructions for L2 cache latency of 100 cycles. The average reduction in execution times, in comparison to original programs, dropped from 30% to 12.5%. Instead of an average reduction in execution times of 10% in comparison to ccmalloc versions of the program we observed an average increase of 9% in execution times. Impact on power consumption. We also compared the power consumption for the compression based programs with that of the original programs and ccmalloc based programs (see Fig. 7). These measurements are based upon the Wattch [1] system which is built on top of the simplescalar simulator. These results track the execution time results quite closely. The average reduction in power consumption over the original programs is around 30% for the small input. The reductions in power dissipation that compression provides over ccmalloc for the different cache latencies is also given. As we can see, on an average, compression reduces the power dissipation by 5%, 10%, and 15% over ccmalloc for L2 cache latencies of 100, 200, and 400 cycles respectively.
26
Youtao Zhang and Rajiv Gupta Comp./Orig.*100 (Latency=100 cycles) Comp./Orig.*100 (Latency=200 cycles) Comp./Orig.*100 (Latency=400 cycles) Comp./ccmalloc*100 (Latency=100 cycles) Comp./ccmalloc*100 (Latency=200 cycles) Comp./ccmalloc*100 (Latency=400 cycles)
120
percentage comparison
100
80
60
40
20
0
add
tree
t
r biso
tsp
ter
ime
per
lth
hea
t
ms
e
rag
ave
Fig. 7. Impact on in power consumption.
Impact on cache performance. Finally in Fig. 8 we present the impact of compression on cache behavior, including I-cache, D-cache and unified L2 cache behaviors. As expected, the I-cache performance is degraded due to increase in code size caused by our current implementation of compression. However, the performances of D-cache and unified cache are significantly improved. This improvement in data cache performance is a direct consequence of compression.
I−cache:Comp./Orig.*100 I−cache:Comp./ccmalloc*100 D−cache:Comp./Orig.*100 D−cache:Comp./ccmalloc*100 U−cache:Comp./Orig.*100 U−cache:Comp./ccmalloc*100
160
140
percentage comparison
120
100
80
60
40
20
0
add
tree
rt
biso
tsp
ter
ime
per
lth
hea
Fig. 8. Impact on cache misses.
t
ms
e
rag
ave
Data Compression Transformations for Dynamically Allocated Data Structures
6
27
Related Work
Recently there has been a lot of interest in exploiting narrow width values to improve program performance [2, 12, 13]. However, our work focusses on pointer intensive applications for which it is important to also handle pointer data. A great deal of research has been conducted on development of locality improving transformations for dynamically allocated data structures. These transformations alter object layout and placement to improve cache performance [14, 6, 15]. However, none of these transformations result in space savings. Existing compression transformations [10, 7] rely upon compile time analysis to prove that certain data items do not require a complete word of memory. They are applicable only when the compiler can determine that the data being compressed is fully compressible and they only apply to narrow width non-pointer data. In contrast, our compression transformations apply to partially compressible data and, in addition to handling narrow width non-pointer data, they also apply to pointer data. Our approach is not only more general but it is also simpler in one respect. We do not require compile-time analysis to prove that the data is always compressible. Instead simple compile-time heuristics are sufficient to determine that the data is likely to be compressible. ISA extensions have been developed to efficiently process narrow width data including Intel’s MMX [9] and Motorola’s AltiVec [11]. Compiler techniques are also being developed to exploit such instruction sets [8]. However, the instructions we require are quite different from MMX instructions because we must handle partially compressible data structures and we must also handle pointer data.
7
Conclusions
In conclusion we have introduced a new class of transformations that apply data compression techniques to compact the sizes of dynamically allocated data structures. These transformations result in large space savings and also result in significant reductions in program execution times and power dissipation due to improved memory performance. An attractive property of these transformations is that they are applicable to partially compressible data structures. This is extremely important because according to our experiments, while the data structures in all of the benchmarks we studied are very highly compressible, they contain small amounts of uncompressible data. Even for programs with fully compressible data structures our approach has one advantage. The application of compression transformations can be driven by simple value profiling techniques [4]. There is no need for complex compile-time analyses for identifying fully compressible fields in data structures. Our approach is applicable to a more general class of programs than existing compression techniques: we can compress pointers as well as non-pointer data; and we can compress partially compressible data structures. Finally we have designed the DCX ISA extensions to enable efficient manipulation of compressed data. The same task cannot be carried using MMX type instructions. Our main contribution is that data compression techniques can now be used to
28
Youtao Zhang and Rajiv Gupta
improve performance of general purpose programs and therefore this work takes the utility of compression beyond the realm of multimedia applications.
References 1. D. Brooks, V. Tiwari, and D. Martonosi, “Wattch: A Framework for ArchitectureLevel Power Analysis and Optimizations,” 27th International Symposium on Computer Architecture (ISCA), pages 83–94, May 2000. 2. D. Brooks and D. Martonosi, “Dynamically Exploiting Narrow Width Operands to Improve Processor Power and Performance,” 5th International Symposium on High-Performance Computer Architecture (HPCA), pages 13–22, Jan. 1999. 3. D. Burger and T.M. Austin, “The Simplescalar Tool Set, Version 2.0,” Computer Architecture News, pages 13–25, June 1997. 4. M. Burrows, U. Erlingson, S-T.A. Leung, M.T. Vandevoorde, C.A. Waldspurger, K. Walker, and W.E. Weihl, “Efficient and Flexible Value Sampling,” The Ninth International Conference on Architectural Support for Programming Languages and Operating Systems (ASPLOS), pages 160–167, Cambridge, MA, November 2000. 5. M. Carlisle, “Olden: Parallelizing Progrms with Dynamic Data Structures on Distributed-Memory Machines,” PhD Thesis, Princeton Univ., Dept. of Comp. Science, June 1996. 6. T.M. Chilimbi, M.D. Hill, and J.R. Larus, “Cache-Conscious Structure Layout,” ACM SIGPLAN Conference on Programming Language Design and Implementation (PLDI), pages 1–12, Atlanta, Georgia, May 1999. 7. J. Davidson and S. Jinturkar, “Memory access coalescing : a technique for eliminating redundant memory accesses,” ACM SIGPLAN Conference on Programming Language Design and Implementation (PLDI), pages 186–195, 1994. 8. S. Larsen and S. Amarasinghe, “Exploiting Superword Level Parallelism with Multimedia Instruction Sets,” ACM SIGPLAN Conf. on Programming Language Design and Implementation (PLDI), pages 145–156, Vancouver B.C., Canada, June 2000. 9. A. Peleg and U. Weiser, MMX Technology Extension to Intel Architecture. 16(4):4250, August 1996. 10. M. Stephenson, J. Babb, and S. Amarasinghe, “Bitwidth Analysis with Application to Silicon Compilation,” ACM SIGPLAN Conf. on Programming Language Design and Implementation (PLDI), pages 108–120, Vancouver B.C., Canada, June 2000. 11. J. Tyler, J. Lent, A. Mather, and H.V. Nguyen, “AltiVec(tm): Bringing Vector Technology to the PowerPC(tm) Processor Family,” Phoenix, AZ, February 1999. 12. Y. Zhang, J. Yang, and R. Gupta, “Frequent Value Locality and Value-Centric Data Cache Design,” The Ninth International Conference on Architectural Support for Programming Languages and Operating Systems (ASPLOS), pages 150–159, Cambridge, MA, November 2000. 13. J. Yang, Y. Zhang, and R. Gupta, “Frequent Value Compression in Data Caches,” The 33nd Annual IEEE/ACM International Symposium on Microarchitecture (MICRO), pages 258–265, Monterey, CA, December 2000. 14. D.N. Truong, F. Bodin, and A. Seznec, “Improving Cache Behavior of Dynamically Allocated Data Structures,” International Conference on Parallel Architectures and Compilation Techniques (PACT), pages 322–329, Paris, France, 1998. 15. B. Calder, C. Krintz, S. John, and T. Austin, “Cache-Conscious Data Placement,” 8th International Conf. on Architectural Support for Programming Languages and Operating Systems (ASPLOS), pages 139–149, San Jose, California, October 1998.
Evaluating a Demand Driven Technique for Call Graph Construction Gagan Agrawal1, Jinqian Li2 , and Qi Su2 1
2
Department of Computer and Information Sciences, Ohio State University Columbus, OH 43210
[email protected] Department of Computer and Information Sciences, University of Delaware Newark DE 19716 {li,su}@eecis.udel.edu
Abstract. With the increasing importance of just-in-time or dynamic compilation and the use of program analysis as part of software development environments, there is a need for techniques for demand driven construction of a call graph. We have developed a technique for demand driven call graph construction which handles dynamic calls due to polymorphism in object-oriented languages. Our demand driven technique has the same accuracy as the corresponding exhaustive technique. The reduction in the graph construction time depends upon the ratio of the cardinality of the set of influencing nodes and the total number of nodes in the entire program. This paper presents a detailed experimental evaluation of the benefits of the demand driven technique over the exhaustive one. We consider a number of scenarios, including resolving a single call site, resolving all call sites in a method, resolving all call sites within all methods in a class, and computing reaching definitions of all actual parameters inside a method. We compare the analysis time, the number of methods analyzed, and the number of nodes in the working set for the demand driven and exhaustive analyses. We use SPECJVM programs as benchmarks for our experiments. Our experiments show for the larger SPECJVM programs, javac, mpegaudio, and jack, demand driven analysis on the average takes nearly an order of magnitude less time than exhaustive analysis.
1
Introduction
A call graph is a static representation of dynamic invocation relationships between procedures (or functions or methods) in a program. A node in this directed graph represents a procedure and an edge (p → q) exists if the procedure p can invoke the procedure q. In program analysis or compiler optimizations for object-oriented programs, call graph construction becomes a critical step for at
This research was supported by NSF CAREER award ACI-9733520 and NSF grant CCR-9808522.
R. N. Horspool (Ed.): CC 2002, LNCS 2304, pp. 29–45, 2002. c Springer-Verlag Berlin Heidelberg 2002
30
Gagan Agrawal et al.
least two reasons. First, because the average size of a method is typically quite small, very limited information is available without performing interprocedural analysis. Second, because of the frequent use of virtual functions, accuracy and efficiency of the call graph construction technique is crucial for the results of interprocedural analysis. Therefore, call graph construction or dynamic call site resolution has been a focus of attention lately in the object-oriented compilation community [3,4,8,9,11,13,14,15,19,20,21,24]. We believe that with an increasing popularity of just-in-time or dynamic compilation and with an increasing use of program analysis in software development environments, there is a need for demand driven call graph analysis techniques. In a dynamic or just-in-time compilation environment, aggressive compiler analysis and optimizations are applied to selected portions of the code, and not to other less frequently executed or never executed portions of the code. Therefore, the set of procedures called needs to be computed for a small set of call sites, and not for all the call sites in the entire program. Similarly, when program analysis is applied in a software development environment, demand driven call graph analysis may be preferable to exhaustive analysis. For example, while constructing static program slices [23], the information on the set of procedures called is required only for the call sites included in the slice and depends upon the slicing criterion used. Similarly, during program analysis for regression testing [16], only a part of the code needs to be analyzed, and therefore, demand driven call graph analysis can be significantly quicker than an exhaustive approach. We have developed a technique for performing demand driven call graph analysis [1,2]. The technique has two major theoretical properties. The worstcase complexity of our analysis is the same as the well known 0-CFA exhaustive analysis technique [18], except that our input is the cardinality of the set of influencing nodes, rather than the total number of nodes in the program representation. Thus, the advantage of our demand driven technique depends upon the ratio of the size of set of influencing nodes and the total number of nodes. Second, we have shown that the type information computed by our technique for all the nodes in the set of influencing nodes is as accurate as the 0-CFA exhaustive analysis technique. This paper presents an implementation and detailed experimental evaluation of our demand driven call graph construction technique. The implementation has been carried out using the sable infrastructure developed at McGill University [22]. Initial work on call graph construction exclusively focused on exhaustive analysis, i.e., analysis of a complete program. Many recent efforts have focused on analysis when entire program may not available, or cannot be analyzed because of memory constraints [6,17,19]. These efforts focus on obtaining most precision with the amount of available information. In comparison, our goal is to reduce the cost of analysis when demand-driven analysis can be performed, but not compromise the accuracy of analysis. We are not aware of any previous work on performing and evaluating demand-driven call graph analysis for the purpose of efficiency, even when the full program is available. Our work is also related to previous work on demand driven data flow analysis [10,12]. Their work assumes
Evaluating a Demand Driven Technique for Call Graph Construction
This
x
This
y
y
This
This
31
y
This
y
This
y
cs1
cs2
x y
Fig. 1. Procedure A::P’s portion of PSG that a call graph is already available and does not, therefore, apply to the demand driven call graph construction problem. The rest of the paper is organized as follows. The demand driven call graph construction technique is reviewed in Section 2. Our experimental design is presented in Section 3 and experimental results are presented in Section 4. We conclude in Section 5.
2
Demand Driven Call Graph Construction
In this section, we review our demand driven call graph construction technique. More details of the technique are available from our previous papers [1,2]. We use the interprocedural representation Program Summary Graph (PSG), initially proposed by Callahan [5], for presenting our demand driven call graph analysis technique. Procedure A::P’s portion of PSG is shown in Figure 1. We also construct a relatively inaccurate initial call graph by performing relatively inexpensive Class Hierarchy Analysis (CHA) [7]. In presenting our technique, we use the following definitions. pred(v) : The set of predecessors of the node v in the PSG. This set is initially defined during the construction of PSG and is not modified as the type information becomes more precise. proc(v) : This relation is only defined if the node v is an entry node or an exit node. It denotes the name of the procedure to which this node belongs. TYPES(v): The set of types associated with a node v in the PSG during any stage in the analysis. This set is initially constructed using Class Hierarchy Analysis, and is later refined through data-flow propagation.
32
Gagan Agrawal et al.
THIS NODE(v): This is the node corresponding to the this pointer at the procedure entry (if v is an entry node), procedure exit (if v is an exit node), procedure call (if v is a call node) or call return (if v is a return node). THIS TYPE(v): If the vertex v is a call node or a return node,THIS TYPE(v) returns the types currently associated with the call node for the this pointer at this call site. This relation is not defined if v is an entry or exit node. PROCS(S): Let S be the set of types associated with a call node for a this pointer. Then, PROCS(S) is the set of procedures that can actually be invoked at this call site. This function is computed using Class Hierarchy Analysis (CHA). We now describe how we compute the set of nodes in the PSG for the entire program that influence the set of procedures invoked at the given call site ci . The PSG for the entire program is never constructed. However, for ease in presenting the definition of the set of influencing nodes, we assume that the PSG components of all procedures in the entire program are connected based upon the initial sound call graph. Let v be the call node for the this pointer at the call site ci . Given the hypothetical complete PSG, the set of influencing nodes (which we denote by S) is the minimal set of nodes such that: 1) v ∈ S, 2) (x ∈ S) ∧ (y ∈ pred(x)) → y ∈ S, and 3) x ∈ S → THIS NODE(x) ∈ S Starting from the node v, we include the predecessors of any node already in the set, until we reach internal nodes that do not have any predecessors. For any node included in the set, we also include the corresponding node for the this pointer (denoted by THIS NODE) in the set. The next step in the algorithm is to perform iterative analysis over the set of nodes in the Partial Program Summary Graph (PPSG) to compute the set of types associated with a given initial node. This problem can be modeled as computing the data-flow set TYPES with each node in the PPSG and refining it iteratively. The initial values of TYPES(v) are computed through class hierarchy analysis that we described earlier in this section. If a formal or actual parameter is declared to be a reference to class cname, then the actual runtime type of that parameter can be any of the subclasses (including itself) of cname. The refinement stage can be described by a single equation, which is shown in Figure 2. Consider a node v in PPSG. Depending upon the type of v, three cases are possible in performing the update: 1) v is a call or exit node, 2) v is an entry node, and 3) v is a return node. In Case 1, the predecessors of the node v are the internal nodes, the entry nodes for the same procedure, or the return nodes at one of the call sites within this procedure. The important observation is that such a set of predecessors does not change as the type information is made more precise. So, the set TYPES(v) is updated by taking union over the sets of TYPES(v) over the predecessors of the node v. We next consider case 2, i.e., when the node v is an entry node. proc(v) is the procedure to which the node v belongs. The predecessors of such a node are call nodes at all call sites at which the function proc(v) can possibly be called, as per the initial call graph assumed by performing class hierarchy analysis.
Evaluating a Demand Driven Technique for Call Graph Construction
TYPES(v) ( p ∈ pred(v) TYPES(p) ) if v is call or exit node TYPES(v) (
TYPES(v)=
(p ∈ pred(v)) ∧ (proc(v) ∈ PROCS(THIS
33
TYPE(p))) TYPES(p) )
if v is an entry node TYPES(v) ( (p ∈ pred(v)) ∧ (proc(p) ∈ PROCS(THIS TYPE(v))) TYPES(p) ) if v is a return node
Fig. 2. Data-flow equation for propagating type information
Such a set of possible call sites for proc(v) gets restricted as interprocedural type propagation is performed. Let p be a call node that is a predecessor of v. We want to use the set TYPES(p) in updating TYPES(v) only if the call site corresponding to p invokes proc(v). We determine this by checking the condition proc(v) ∈ PROCS(THIS TYPE(p)). The function THIS TYPE(p) determines the types currently associated with the this pointer at the call site corresponding to p and the function PROCS determines the set of procedures that can be called at this call site based upon this type information. Case 3 is very similar to the case 2. If the node v is a return node, the predecessor node p to v is an exit node. We want to use the set TYPES(p) in updating TYPES(v) only if the call site corresponding to v can invoke the function proc(p). We determine this by checking the condition proc(p) ∈ PROCS(THIS TYPE(v)). The function THIS TYPE(v) determines the types currently associated with the this pointer at the call site corresponding to v and the function PROCS determines the set of procedures that can be called at this call site based upon this type information. Theoretical Results: The technique has two major theoretical properties [2]. The worst-case complexity of our analysis is the same as the well known 0-CFA exhaustive analysis technique [18], except that our input is the cardinality of the set of influencing nodes, rather than the total number of nodes in the program representation. Thus, the advantage of our demand driven technique depends upon the ratio of the size of set of influencing nodes and the total number of nodes. Second, we have shown that the type information computed by our technique for all the nodes in the set of influencing nodes is as accurate as the 0-CFA exhaustive analysis technique.
3
Experiment Design
We have implemented our demand driven technique using the sable infrastructure developed at McGill University [22]. In this section, we describe the design of the experiments conducted, including benchmarks used, scenarios used for evaluating demand driven call graph constructions, and metrics used for comparison. Benchmark Programs: We have primarily used programs from the most commonly used benchmark set for Java programs, SPECJVM. The 10 SPECJVM programs are check, compress, jess, raytrace, db, javac, mpegaudio, mtrt,
34
Gagan Agrawal et al.
Benchmark no. of no. of no. of classes methods PSG nodes check 20 96 3954 compress 15 35 601 jess 8 41 1126 raytrace 28 130 6518 db 6 34 1452 javac 180 1004 48147 mpegaudio 58 270 6205 mtrt 4 6 51 jack 61 261 14080 checkit 6 8 495
Fig. 3. Description of benchmarks
jack, and checkit. The total number of classes, methods, and PSG nodes for each of these benchmarks is listed in Figure 3. The number of classes ranges from 4 to 180, the number of methods ranges from 6 to 1004, and the number of PSG nodes ranges from 51 to 48147. Scenarios for Experiments: In Section 2, our technique was presented under the assumption that the call graph edges need to be computed for a single call site. In practice, demand driven analysis may be invoked under more complex scenarios. For example, one may be interested in knowing the reaching definitions for a set of variables in a method. Performing this analysis may require knowing the methods invoked at a set of call sites in the program. Thus, demand driven call graph analysis may be performed to determine the call graph edges at the call sites within this set. Alternatively, there may be interest in fully analyzing a single method or a class, and selectively analyzing codes from other methods or classes to have more precise information within the method or class. We have conducted experiments to evaluate demand driven call graph construction under the following scenarios: – Experiment A: Resolving a single call site in the program. We have only considered the call sites that can potentially invoke multiple methods after Class Hierarchy Analysis (CHA) is applied. This is the simplest case for the demand driven technique, and should require analyzing only a small set of procedures and PSG nodes in the program. – Experiment B: Computing reaching definitions of all actual parameters at all call sites within a method. Computing interprocedural reaching definitions will typically require knowing calling relationship at a set of call sites. This scenario depicts a situation in which demand driven call graph construction is invoked while computing certain data-flow information on a demand basis. – Experiment C: Resolving all call sites within a method. This is more complicated than the experiment A above, and represents a more realistic case when interprocedural optimizations are applied at a portion of the program.
Evaluating a Demand Driven Technique for Call Graph Construction
35
– Experiment D: Resolving all call sites within all methods within a class. This scenario represents analyzing a single class, but performing selective analysis on portions of code from other classes to improve the accuracy of analysis within the class. Metrics Used: We now describe the metrics used for reporting the benefits of demand driven call graph construction over exhaustive call graph analysis. Performing demand driven analysis will require fewer PSG nodes to be analyzed, fewer procedures to be analyzed, and should require lesser time. We individually report these three factors. Specifically, the three metrics used are: – Time Ratio: This is the ratio of the time required for demand driven analysis, as compared to exhaustive analysis. This metric evaluates the benefits of using demand driven analysis, but is dependent on our implementation. – Node Ratio: This is the ratio of the number of nodes in PPSG to the total number of nodes in PSG of the entire program. This metric is an implementation independent indicative of the benefits of the analysis. – Procedure Ratio: This is the ratio of the number of methods analyzed during demand driven analysis, as compared to the total number of methods in the entire program. Since each method’s portion of the full program representation used in our analysis is constructed only if that method needs to be analyzed, and is always constructed in entirety if the methods needs to be analyzed; this metric demonstrates the space-efficiency of demand driven call graph construction.
4
Experimental Results
We now present the results from our experiments. Our experiments were conducted on a Sun 250 MHz Ultra-Sparc processor with 512 MB of main memory. We first present results from exhaustive analysis. Then, we present results from demand driven analysis for scenarios A, B, C, and D. Exhaustive Analysis: To provide a comparison against demand driven analysis, we first include the results from exhaustive 0-CFA call graph construction on our set of benchmarks. The results from exhaustive analysis are presented in Figure 4. The time required for Class Hierarchy Analysis (CHA), time required for the iterative call graph refinement, and the number of call sites that are not-monomorphic after applying CHA are shown here. Call sites that can potentially invoke multiple methods after CHA has been applied are the ones that can benefit from more aggressive iterative analysis. The time required in CHA phase in our implementation is dominated by setting up of data-structures, and turns out to be almost the same for all benchmarks. The time required for the iterative refinement phase varies a lot between benchmarks, and is roughly proportional to the size of the benchmark. Two important observations from the Figure 4 are as follows. First, only 4 of the 10 programs have call sites that are polymorphic after the results of
36
Gagan Agrawal et al.
Benchmark CHA time Iter. Analysis Polymorphic Call Sites (sec.) (sec.) After CHA check 72.3 27.7 0 compress 84.5 13.3 0 jess 96.5 59.4 0 raytrace 82.1 60.9 39 db 72.8 12.0 0 javac 85.6 2613 577 mpegaudio 73.4 462 35 mtrt 80.2 3.5 0 jack 74.1 250.7 77 checkit 73.6 5.3 0
Fig. 4. Results from exhaustive analysis
CHA are known. These 4 programs are raytrace, javac, mpegaudio, and jack. These are also the 4 largest programs among the programs in this benchmark set, comprising 28 to 180 classes and 130 to 1004 methods. For the smaller programs, CHA is as accurate as any analysis for constructing the call graph. The second observation is that for 7 of 10 programs, the total time required for exhaustive call graph construction is dominated by the CHA phase. For the three remaining programs, javac, mpegaudio, and jack, the time required for iterative analysis is 30 times, 6 times, and nearly 4 times the time required for CHA analysis, respectively. Therefore, for the smaller programs in the benchmark set, CHA analysis is sufficient, and they do not benefit from more aggressive analysis. The dominant cost of analysis is CHA, which remains the same during demand driven call graph construction. So, these programs cannot benefit from demand driven analysis. On the other hand, the time required for analysis is dominated by the iterative phase in the larger programs. A large number of call sites are polymorphic after applying CHA, and are therefore likely to benefit from iterative analysis. Since the iterative analysis is applied on a much small number of nodes in the demand driven technique, these programs are likely to benefit from the proposed demand driven analysis. This is analyzed in details in the remaining part of this section. Experiment A: In the first set of experiments, we perform demand driven analysis to resolve a single call site in the program. We only consider call sites that are known to potentially invoke multiple procedures after CHA has been applied. As we described in the previous subsection, only raytrace, javac, mpegaudio, and jack contain such polymorphic call sites. Therefore, the results are only presented from these call sites. The averages for time ratio, node ratio, and procedure ratio for these 4 programs is shown in Figure 5. The analysis time compared in this table is the time
Evaluating a Demand Driven Technique for Call Graph Construction
Benchmark No. of Cases raytrace javac mpegaudio jack
39 577 35 77
37
Analysis Time PPSG Nodes Procedures Avg. (sec.) Ratio Avg. No. Ratio Avg. No. Ratio 3.78 6.2% 96.6 1.5% 13.7 10.5% 341.2 13.1% 9831 20.4% 747 74.5% 15.6 3.3% 186.3 3.0% 31.9 11.8% 11.8 4.7% 422.3 2.9% 46.1 17.6%
Fig. 5. Results from experiment A
for iterative analysis only. For both demand driven and exhaustive versions, additional time is spent in performing CHA. The average of the ratio of the number of nodes that need to be analyzed during demand driven analysis is extremely low for raytrace, mpegaudio, and jack, ranging between 1.5% and 3.0%. This results in an average iterative analysis time ratio of less than 7%. Even the number of procedures that need to be analyzed is less than 20% for these three programs. The results for javac are significantly different, but still demonstrate gains from the use of demand driven analysis. The average node ratio is 20.4%, resulting in an average time ratio of 13.1%. However, the average procedure ratio is nearly 75%. This means that for most of the cases, a very large fraction of procedures need to be involved in demand driven analysis. Use of demand driven analysis does not result in significant space savings for javac. After including the time for CHA, the average time ratio are 60%, 16%, 17%, and 26% for raytrace, javac, mpegaudio, and jack, respectively. The gains from demand driven analysis for raytrace are limited, because the time required for CHA exceeds the exhaustive iterative analysis time. javac, which had the highest ratio before CHA time was included, has the lowest ratio after including CHA because the time required for exhaustive iterative analysis is more than 30 times the time required for CHA. Demand driven analysis gives clear benefits in the case of javac, mpegaudio, and jack, because the time required for the iterative phase dominates the time required for CHA. To further study the results from these three benchmarks, we present a series of cumulative frequency graphs. For the experiment A, cumulative frequency graphs for the benchmarks javac, mpegaudio, and jack are presented in Figures 6, 7, and 9, respectively. A point (x, y) in such a graph means that the fraction x of the cases in the experiments had a ratio of less than or equal to y. The results from javac follow an interesting trend. 56 of the 577 cases require analysis of 120 or fewer procedures, or nearly 12% of all procedures. The same set of cases requires analyzing 257 or fewer nodes, or less than 1% of all nodes. The time taken for these cases is also less than 2% of the time for exhaustive analysis. However, the ratios are very different for the remaining cases. The next 413 cases require analysis of the same set of 837 procedures, or 83% of all procedures. The remaining cases require between 838 and 876 procedures to be
38
Gagan Agrawal et al.
analyzed. The analysis time is between 15% and 20% of the exhaustive analysis time, and the number of nodes involved for these cases is nearly 25% of the total number of nodes. The results from mpegaudio are as follows. 11 of the 35 cases require analysis of between 73 and 98 procedures, or between 27% and 36% of all procedures. The same 11 cases require analysis of between 8% and 10% of nodes, and between 2% and 4% of time. The other 24 cases require analysis of less than 12% of all procedures, and less than 1.5% of nodes and time. For jack, 61 of 77 cases require analysis of 59 or 57 procedures, or nearly 20% of all procedures. The same set of cases require between 4% and 6% of time, and 2% and 4% of all nodes. The other 16 cases involve analyzing less than 5% of all procedures, less than 1% of time, and less than 0.5% of all nodes.
0
0
10
10
−1
10
−1
Ratio
Ratio
10
−2
10
−2
10 −3
10
Time ratio Node ratio Proc ratio
Time ratio Node ratio Proc ratio
−4
10
−3
0
0.1
0.2
0.3
0.4 0.5 0.6 Cumulative Frequency
0.7
0.8
0.9
1
Fig. 6. Experiment A: Cumulative frequency of time, node, and procedure ratio for javac
10
0
0.1
0.2
0.3
0.4 0.5 0.6 Cumulative Frequency
0.7
0.8
0.9
1
Fig. 7. Experiment A: Cumulative frequency of time, node, and procedure ratio for mpegaudio
Experiment B: In the second set of experiments, we evaluated the performance of demand driven call graph construction when it is initiated from demand driven data flow analysis. The particular data flow problem we consider is the computation of reaching definitions for all actual parameters in a procedure. We report results from this experiment only on raytrace, mpegaudio, and jack. The 6 smaller programs in SPECJVM benchmark set do not contain any polymorphic call sites. Even after many attempts, we could not complete this experiment for javac, which is the largest program in this benchmark set. We believe that it was because of very large memory requirements when reaching definition and call graph construction analyses are combined. The average time, node, and procedure ratios for the three benchmarks are presented in Figure 8. As compared to the experiment A, we are reporting results from a significantly larger number of cases, because this analysis was performed on all procedures. At the same time, for many cases in experiment B resolution of several polymorphic call sites may be required. The three ratios for mpegaudio are lower for the experiment B, as compared to the ones obtained from experi-
Evaluating a Demand Driven Technique for Call Graph Construction
39
Benchmark No. of Cases raytrace mpegaudio jack
Analysis Time PPSG Nodes Procedures Avg. (sec.) Ratio Avg. No. Ratio Avg. No. Ratio 129 4.36 7.2% 354.3 5.4% 28.7 22.0% 270 5.48 1.2% 133.5 2.2% 26.8 9.9% 261 15.44 6.2% 524.9 3.7% 94.8 36.6 %
Fig. 8. Results from experiment B
0
0
10
10
−1
−1
10
Ratio
Ratio
10
−2
10
−3
−2
10
−3
10
10
Time Ratio Node ratio Proc ratio
Time Ratio Node ratio Proc ratio
−4
10
−4
0
0.1
0.2
0.3
0.4 0.5 0.6 Cumulative Frequency
0.7
0.8
0.9
1
Fig. 9. Experiment A: Cumulative frequency of time, node, and procedure ratio for jack
10
0
0.1
0.2
0.3
0.4 0.5 0.6 Cumulative Frequency
0.7
0.8
0.9
1
Fig. 10. Experiment B: Cumulative frequency of time, node, and procedure ratio for mpegaudio
ment A. For raytrace and jack, the reverse is true; the three ratios are higher for the experiment B. The ratio for iterative analysis time are 7.2%, 1.2%, and 6.2% for raytrace, mpegaudio, and jack, respectively. After including the time for CHA, the ratios of the time required are 60%, 14%, and 27%, respectively. We studied the results in more details for mpegaudio and jack. The cumulative frequency plots for these two benchmarks are presented in Figures 10 and 11, respectively. The results from mpegaudio are as follows. 192 of 270 cases require analysis of 33 or fewer procedures, or less than 12% of all procedures. The same set of cases require analysis of less than 2% of all nodes, and take less than 1% of time for exhaustive analysis. For the remaining cases, the number of procedures to be analyzed is distributed fairly uniformly between 66 and 118. For jack, the trends are very different. 126 of 261 cases require analysis of 162 or 161 procedures, or nearly 62% of all procedures. The same set of cases require analysis of nearly 800 nodes, or 6% of all nodes. The time required for this set of cases is nearly 9% of the time for exhaustive iterative analysis. The portions of the program that need to be analyzed for this set of cases (48% of all cases) is almost the same. This has the following implications. If demand driven analysis is performed for one of these cases, and then needs to be performed for another case in the same set, very limited additional effort will be required.
40
Gagan Agrawal et al. 0
0
10
10
−1
−1
10
10
−2
−2
Ratio
10
Ratio
10
−3
−3
10
10
−4
−4
10
10
Time ratio Node ratio Proc ratio
Time ratio Node ratio Proc ratio
−5
10
−5
0
0.1
0.2
0.3
0.4 0.5 0.6 Cumulative Frequency
0.7
0.8
0.9
1
Fig. 11. Experiment B: Cumulative frequency of time, node, and procedure ratio for jack Benchmark No. of Cases raytrace javac mpegaudio jack
130 1004 270 261
10
0
0.1
0.2
0.3
0.4 0.5 0.6 Cumulative Frequency
0.7
0.8
0.9
1
Fig. 12. Experiment C: Cumulative frequency of time, node, and procedure ratio for javac
Analysis Time PPSG Nodes Procedures Avg. (sec.) Ratio Avg. No. Ratio Avg. No. Ratio 4.51 7.4% 358.9 5.5% 29.1 22.4% 271.1 10.3% 7634.5 15.8 % 587 58.5% 5.37 1.2% 133.5 2.1 % 26.8 9.9% 14.9 5.9% 524.9 3.7% 94.8 36.3%
Fig. 13. Results from experiment C
Experiment C: Our next set of experiments evaluated the performance of demand driven call graph construction when all call sites in a procedures had to be resolved. We present data only from raytrace, javac, mpegaudio, and jack, because they contain polymorphic call sites. For these programs, we include results from analysis of all methods, even if they do not contain any polymorphic call site. The averages of time, node, and procedure ratios are presented in Figure 13. The averages are very close to the results for experiment B. We believe that this because all call sites in a method had to be resolved for experiment C, and all cites that can potentially invoke a method had to be resolved for experiment B. The three ratios for javac are lower for experiment C, as compared to the experiment A. This is because the averages are taken over much larger number of cases in the experiment C. Many of the procedures do not require analysis of any polymorphic call site, and contribute to a lower overall average. The cumulative frequency plots for javac, mpegaudio, and jack are presented in Figures 12, 14, and 15, respectively. Results from javac for experiment C are similar to the results from experiment A, with one important difference. A larger fraction of cases can be analyzed with a small fraction of procedures and nodes. 316 of 1004 cases require between 1 and 125 procedures, or up to 12% of all procedures. The remaining 688 cases
Evaluating a Demand Driven Technique for Call Graph Construction
41
require between 837 and 907 procedures, nearly 25% of all nodes, and nearly 15% of exhaustive analysis time. Results from mpegaudio for experiment C are very similar to the results from experiment B. 192 of 270 cases (the same number as in experiment B) require analysis of at most 33 procedures, while the remaining cases need analysis of between 66 and 118 procedures. The same trend (closeness between results from experiments B and C) continues for jack.
0
0
10
10
−1
10 −1
10
−2
Ratio
Ratio
10 −2
10
−3
10
−3
10
−4
10
Time ratio Node ratio Proc ratio
Time ratio Node ratio Proc ratio
−4
10
−5
0
0.1
0.2
0.3
0.4 0.5 0.6 Cumulative Frequency
0.7
0.8
0.9
1
Fig. 14. Experiment C: Cumulative frequency of time, node, and procedure ratio for mpegaudio
10
0
0.1
0.2
0.3
0.4 0.5 0.6 Cumulative Frequency
0.7
0.8
0.9
1
Fig. 15. Experiment C: Cumulative frequency of time, node, and procedure ratio for jack
Experiment D: Our final set of experiments evaluates demand driven analysis when all call sites in all procedures of a class are to be resolved. Figure 16 presents average time ratio, node ratio, and procedure ratio for raytrace, javac, mpegaudio, and jack. Even though each invocation of demand driven analysis may involve resolving several call sites, the ratio are quite small. For raytrace, mpegaudio, and jack, the averages of time ratios and node ratios are still less than 10%. The averages for javac are a bit higher, consistent with the previous experiments. The average time ratio and node ratio are 13.1% and 20.6%, respectively. Space savings are not significant with javac, but quite impressive for the other three benchmarks. After including the time required for CHA, the average time ratio is 61% for raytrace, 16% for javac, 16% for mpegaudio, and 25% for jack. In comparison with the results from experiment C, the averages of ratios from experiment D are all higher for raytrace, javac, and mpegaudio, as one would normally expect. The surprising results are from jack, where all three ratios are lower in experiment D. The explanation for this is as follows. The results from experiment D are averaged over a smaller number of cases, specifically, 61 instead of 261 for jack. It turns out that the procedures that require the most time, number of nodes, and number of procedures to be analyzed belong to a small set of classes. Therefore, they contribute much more significantly to the
42
Gagan Agrawal et al.
Benchmark No. of Cases raytrace javac mpegaudio jack
28 180 58 61
Analysis Time PPSG Nodes Procedures Avg. (sec.) Ratio Avg. No. Ratio Avg. No. Ratio 5.32 8.7% 598.3 9.2% 41.5 31.9% 343.6 13.1% 9940 20.6% 741.3 73.8% 14.1 3.1% 280.5 4.5% 47.6 17.6 % 7.49 4.7% 291.3 2.1% 27.6 10.5%
Fig. 16. Results from experiment D
0
0
10
10
−1
10
−1
10
−2
Ratio
Ratio
10
−2
10
−3
10
−3
10 −4
10
Time ratio Node ratio Proc ratio
Time ratio Node ratio Proc ratio
−5
10
−4
0
0.1
0.2
0.3
0.4 0.5 0.6 Cumulative Frequency
0.7
0.8
0.9
1
Fig. 17. Experiment D: Cumulative frequency of time, node, and procedure ratio for javac
10
0
0.1
0.2
0.3
0.4 0.5 0.6 Cumulative Frequency
0.7
0.8
0.9
1
Fig. 18. Experiment D: Cumulative frequency of time, node, and procedure ratio for mpegaudio
average ratios in the results from the experiment C, than in the results from experiment D. Details of the results from javac, mpegaudio, and jack are presented in Figures 17, 18, and 19, respectively. Again, the results from javac are very different from the results on the other two benchmarks. In javac, 20 of the 180 classes can be resolved by analyzing a small fraction of procedures. Specifically, these cases require analysis of between 1 and 63 procedures, i.e., less than 7% of all procedures in the program. However, the other 160 cases require analysis of between 837 and 963 procedures in the program. Each of the cases from this set requires analyzing nearly 25% of all the nodes in the program, and between 15% and 20% of the time for exhaustive analysis. However, the sets of influencing nodes that need to analyzed for these cases are almost identical. Our theoretical result, therefore, implies that after one of these cases has been analyzed, the time required for other cases will be very small. For mpegaudio, the number of procedures that need to be analyzed for the 58 cases ranges from 1 to 139, or from less than 1% to nearly 50%. The distribution is fairly uniform. The time required for demand driven analysis for these cases also has a fairly uniform distribution, between 0.1 second to 22.5 second, or between 0.02% to 5% of the time required for exhaustive analysis. Similarly, the
Evaluating a Demand Driven Technique for Call Graph Construction
43
0
10
−1
Ratio
10
−2
10
−3
10
Time ratio Node ratio Proc ratio −4
10
0
0.1
0.2
0.3
0.4 0.5 0.6 Cumulative Frequency
0.7
0.8
0.9
1
Fig. 19. Experiment D: Cumulative frequency of time, node, and procedure ratio for jack number of nodes ranges from 2 to 880, or from 0.03% to 13%. The results from jack are similar.
5
Conclusions
We have presented evaluation of an algorithm for resolving call sites in an object oriented program on a demand driven fashion. The summary of our results using SPECJVM benchmarks is as follows: – The time required for Class Hierarchy Analysis (CHA), which is a prerequisite for both exhaustive and demand driven iterative analysis, dominates the exhaustive call graph construction time for 7 of the 10 SPECJVM programs. However, CHA itself is sufficient for constructing an accurate call graph for 6 of these 7 programs. The time required for exhaustive iterative analysis clearly dominates CHA time for the three largest SPECJVM programs, javac, mpegaudio, and jack. – For resolving a single call site, demand driven iterative analysis averages at nearly 10% of the time required for exhaustive iterative analysis. The number of nodes that need to be analyzed averages at nearly 3% for mpegaudio and jack, but around 20% for javac. The number of procedures that need to be analyzed is less than 20% for mpegaudio and jack, but nearly 75% for javac. – The averages for the number of nodes and procedures analyzed and the time taken surprisingly stays low when all call sites within a class or a method are analyzed instead of a single call site. This is because the program portions that need to be analyzed for resolving different call sites within a method or a class are highly correlated.
44
Gagan Agrawal et al.
References 1. Gagan Agrawal. Simultaneous demand-driven data-flow and call graph analysis. In Proceedings of International Conference on Software Maintainance (ICSM), September 1999. 30, 31 2. Gagan Agrawal. Demand-drive call graph construction. In Proceedings of the Compiler Construction (CC) Conference, March 2000. 30, 31, 33 3. David Bacon and Peter F. Sweeney. Fast static analysis of c++ virtual function calls. In Eleventh Annual Conference on Object-Oriented Programming Systems, Languages, and Applications (OOPSLA ’96), pages 324–341, October 1996. 30 4. Brad Calder and Dirk Grunwald. Reducing indirect function call overhead in C++ programs. In Conference Record of POPL ’94: 21st ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages, pages 397–408, Portland, Oregon, January 1994. 30 5. D. Callahan. The program summary graph and flow-sensitive interprocedural data flow analysis. In Proceedings of the SIGPLAN ’88 Conference on Programming Language Design and Implementation, Atlanta, GA, June 1988. 31 6. R. Chatterjee, B. G. Ryder, and W. A. Landi. Relevant Context Inference. In Proceedings of the Conference on Principles of Programming Languages (POPL), pages 133–146, January 1999. 30 7. Jeffrey Dean, Craig Chambers, and David Grove. Selective specialization for object-oriented languages. In Proceedings of the ACM SIGPLAN’95 Conference on Programming Language Design and Implementation (PLDI), pages 93–102, La Jolla, California, 18–21 June 1995. SIGPLAN Notices 30(6), June 1995. 31 8. Greg DeFouw, David Grove, and Craig Chambers. Fast interprocedural class analysis. In Proceedings of the POPL’98 Conference, 1998. 30 9. A. Diwan, K. S. McKinley, and J. E. B. Moss. Using Types to Analyze and Optimize Object-Oriented Programs. ACM Transactions on Programming Languages and Systems, 23(1):30–72, January 2001. 30 10. E. Duesterwald, R. Gupta, and M. L. Soffa. A Practical Framework for DemandDriven Interprocedural Data Flow Analysis. ACM Transactions on Programming Languages and Systems, 19(6):992–1030, November 1997. 30 11. David Grove, Greg DeFouw, Jeffrey Dean, and Craig Chambers. Call graph construction in object-oriented languages. In Proceedings of the Conference on Object Oriented Programming Systems, Languages and Applications, 1997. 30 12. S. Horwitz, T. Reps, and M. Sagiv. Demand interprocedural dataflow analysis. In In SIGSOFT ’95: Proceedings of the Third ACM SIGSOFT Symposium on the Foundations of Software Engineering, pages 104–115, 1995. 30 13. Jens Palsberg and Patrick O’Keefe. A type system equivalent to flow analysis. In Conference Record of POPL ’95: 22nd ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages, pages 367–378, San Francisco, California, January 1995. 30 14. Hemant Pande and Barbara Ryder. Data-flow-based virtual function resolution. In Proceedings of the Third International Static Analysis Symposium, 1996. 30 15. M. Porat, M. Biberstein, L. Koved, and M. Mendelson. Automatic detection of immutable fields in Java. In Proceedings of CASCON, 2000. 30 16. Gregg Rothermel and M. J. Harrold. Analyzing regression test selection. IEEE Transactions on Software Engineering, 1996. 30 17. Atanas Routnev, Barbara G. Ryder, and William Landi. Data-Flow Analysis of Program Fragments. In Proceedings of the Conference on Foundations of Software Engineering (FSE), pages 235–253, September 1999. 30
Evaluating a Demand Driven Technique for Call Graph Construction
45
18. O. Shivers. The semantics of Scheme control-flow analysis. In Proceedings of the Symposium on Partial Evaluation and Semantics-Based Program Manipulation, volume 26, pages 190–198, New Haven, CN, June 1991. 30, 33 19. V. C. Sreedhar, M. Burke, and J. D. Choi. A Framework for Interprocedural Optimization in the Presence of Dynamic Class Loading. In Proceedings of ACM SIGPLAN Conference on Programming Language Design and Implementation (PLDI), 2000. 30 20. Vijay Sundaresan, Laurie Hendren, Chrislain Razafimahefa, Raja Vallee-Rai, Patrick Lam, Etienne Gagnon, and Charles Godin. Practical virtual method call resolution for Java. In Fifteenth Annual Conference on Object-Oriented Programming Systems, Languages, and Applications (OOPSLA ’2000), pages 264–280. ACM Press, October 2000. 30 21. Frank Tip and Jens Palsberg. Scalable propagation-based call graph construction algorithms. In Fifteenth Annual Conference on Object-Oriented Programming Systems, Languages, and Applications (OOPSLA ’2000), pages 281–293. ACM Press, October 2000. 30 22. Raja Vallee-Rai. Soot: A Java ByteCode Optimization Framework. Master’s thesis, McGill University, 1999. 30, 33 23. Mark Weiser. Program slicing. IEEE Transactions on Software Engineering, 10:352–357, 1984. 30 24. A. Zaks, V. Feldman, and N. Aizikowitz. Sealed calls in java packages. In Proceedings of Conference on Object Oriented Programming Systems and Languages (OOPSLA), pages 83–92. ACM Press, October 2000. 30
A Graph–Free Approach to Data–Flow Analysis Markus Mohnen Lehrstuhl f¨ ur Informatik II, RWTH Aachen, Germany
[email protected] Abstract. For decades, data–flow analysis (DFA) has been done using an iterative algorithm based on graph representations of programs. For a given data–flow problem, this algorithm computes the maximum fixed point (MFP) solution. The edge structure of the graph represents possible control flows in the program. In this paper, we present a new, graph–free algorithm for computing the MFP solution. The experimental implementation of the algorithm was applied to a large set of samples. The experiments clearly show that the memory usage of our algorithm is much better: Our algorithm always reduces the amount of memory and reached improvements upto less than a tenth. In the average case, the reduction is about a third of the memory usage of the classical algorithm. In addition, the experiments showed that the runtimes are almost the same: The average speedup of the classical algorithm is only marginally greater than one.
1
Introduction
Optimising compilers perform various static program analyses to obtain informations needed to apply optimisations. In the context of imperative languages, the technique commonly used is data–flow analysis (DFA). It provides information about properties of the states that may occur at a given program point during execution. Here, programs considered are intermediate code, e.g. three address code, register code, or Java Virtual Machine (JVM) code [LY97]. For decades, the de facto classical algorithm for DFA has been an iterative algorithm [MJ81, ASU86, Muc97] which uses a graph as essential data structure. The graph is extracted from the program, making explicit the possible control flows in the program as the edge structure of the graph. Typically, the nodes of the graph are basic blocks (BB), i.e. maximal sequences of straight–line code (but see also [KKS98] for comments on the adequacy of this choice). A distinct root node of the graph corresponds to the entry point of the program. For a given graph and a given initial annotation of the root node, the algorithm computes an annotation for each of the nodes. Each annotation captures the information about the state of the execution at the corresponding program point. The exact relation between annotations and states depends on the data– flow problem. However, independently of the exact relation, the annotations computed by the algorithm are guaranteed to be the greatest solution of the consistency equations imposed by the data–flow problem. This result is known as the maximal fixed point (MFP) solution. R. N. Horspool (Ed.): CC 2002, LNCS 2304, pp. 46–61, 2002. c Springer-Verlag Berlin Heidelberg 2002
A Graph–Free Approach to Data–Flow Analysis
47
In the context of BB graphs, there is a need for an additional post–processing of the annotations. Since each BB represents a sequence of instructions, the annotation for a single BB must be propagated to the instruction level. As a result of this post–processing, each program instruction is annotated. The contribution of this paper is an alternative algorithm for computing the MFP solution. In contrast to the classical algorithm, our approach is graph–free: Besides a working set, it does not need any additional data structures (of course, the graph structure is always there implicitly in the program). The key idea is to give the program a more active role: While the classical approach transforms the program to a passive data object on which the solver operates, our point of view is that the program itself executes on the annotation. An obvious advantage of this approach is the reduced memory usage. In addition, it is handy if there is already machinery for execution of programs available. Consequently, our execution–based approach is advantageous in settings where optimisations are done immediately before execution of the code. Here it saves effort to implement the analyses and it saves valuable memory for the execution. The most prominent example of such a setting is the Java Virtual Machine (JVM) [LY97]. In fact, the JVM specification requires that each class file is verified at linking time by a data–flow analyser. The purpose of this verification is to ensure that the code is well–typed and that no operand stack overflows or underflows occur at runtime. In addition, certain optimisations cannot be done by the Java compiler producing JVM code. For instance, optimisation w.r.t. memory allocation like compile–time garbage collection (CTGC) can only be done in the JVM since the JVM code does not provide facilities to influence the memory allocation. CTGC was originally proposed in the context of functional languages [Deu97, Moh97] and then adopted for Java [Bla98, Bla99]. To validate the benefits of our approach, we studied the performance of the new algorithm in competition with the classical one, both in terms of memory usage and runtime. Therefore, we applied both to a large set of samples. The experiments clearly show that the memory usage of our algorithm is much better: Our algorithm always reduces the amount of memory and reached improvements upto less than a tenth. In the average case, the reduction is about a third of the memory usage of the classical algorithm. Moreover, the runtimes are comparable in the average case: Using the classical algorithm does not give a substantial speedup. Structure of this article. We start by defining some basic notions. In Section 3 the classical, iterative algorithm for computing the MFP solution is discussed briefly. Our main contribution starts with Section 4 where we present the new execution algorithm, discuss its relation to the classical algorithm, and prove the termination and correctness. Experimental results presented in Section 5 give an estimation of the benefits our method. Finally, Section 6 concludes the paper.
48
2
Markus Mohnen
Notations
In this section, we briefly introduce the notations that we use in the rest of the paper. Although we focus on abstract interpretation based DFA, our results are applicable to other DFAs as well. The programs we consider are three–address code programs, i.e. non–empty sequences of instructions I ∈ Instr. Each instruction I is either a jump, which can be conditional (if ψ goto n) or unconditional (goto n), or an assignment (x:=y◦z). In assignments, x must be a variable, and y and z can be variables or constants. Since we consider intraprocedural DFA only, we do not need instructions for procedure calls or exits. The major point of this setting is to distinguish between instructions which cause the control flow to branch and those which keep the control flow linear. Hence, the exact structure is not important. Any other intermediate code, like the JVM code, is suitable as well. To model program properties, we use lattices L = A, , where A is a set, and and are binary meet and join operations on A. Furthermore, ⊥ and are least and greatest element of the lattice. Often, finite lattices are used, but in general it suffices to consider lattices which have only finite chains. The point of view of DFA based on abstract interpretation [CC77, AH87] is to replace the standard semantics of programs by an abstract semantics describing how the instructions operate on the abstract values A. Formally, we assume a monotone semantic functional ![.!] : Instr → (A → A) which assigns a function on A to each instruction. A data–flow problem is a quadruple (P, L, ![.!], a0 ) where P = I0 . . . In ∈ Instr+ is a program, L is a lattice, ![.!] is an abstract semantics, and a0 ∈ A is an initial value for the entry of P . To define the MFP solution of a data–flow problem, we first introduce the notion of predecessors. For a given program P = I0 . . . In ∈ Instr+ , we define the function predP : {0, . . . , n} → P({0, . . . , n}) in the following way: j ∈ predP (i) iff either Ij ∈ {goto i, if ψ goto i}, or i = j + 1 and Ij = goto t for some t. Intuitively, the predecessors of an instruction are all instructions which may be executed immediately before it. The MFP solution is a vector of values s0 , . . . , sn ∈ A. Each entry si is the abstract value valid immediately before the instruction Ii . It is defined as the great est solution of the equation system si = j∈predP (i) ![Ij !](sj ). The well–known fixed point theorem by Tarski guarantees the existence of the MFP solution in this setting. Example 1 (Constant Folding Propagation). We now introduce an example, which we use as a running example in the rest of the paper. Constant folding and propagation aims at finding as many constants as possible at compile time, and replacing the computations with the constant values. In the setting described above, we associate with each variable and each program point the information if the variable is always constant at this point. For simplicity, we assume that the program only uses the arithmetic operations on integers. We define a set C := Z {, ⊥} and a relation c1 ≤ c2 iff (a) c1 = c2 , (b) c1 = ⊥, or
A Graph–Free Approach to Data–Flow Analysis
49
(c) c2 = . Intuitively, values can be interpreted in the following way: An integer means “constant value”, means “not constant due to missing information”, and ⊥ means “not constant due to conflict”. The relation ≤ induces meet and join operations. Hence, C, , is a (non–finite) lattice with only finite chains. Fig. 1 shows the corresponding Hasse diagram. The abstract lattice is defined in terms of this lattice. Formally, let X be the set of variables of a program P . By definition, X is finite. We define the set of abstract values as C := X → C, the set of all functions mapping a variable to a value in C. Since X is finite, C is finite as well. We obtain meet and join operations C , C in the canonical way by argument–wise use of the corresponding operation on C. Hence, our lattice for this abstract interpretation is C, C , C . The abstract semantics ![.!]C : Instr → (C → C) is defined in the following way: For jumps, we define ![goto l!]C and ![if ψ goto l!]C to be the identity, since jumps do not change any variable. For assignments, we define ![x:=y◦z!]C := c → c , where c = c[x/a], i.e. c is the same function as c except at argument x. The new value is defined as ay ◦ az if y = ay ∈ Z or c(y) = ay ∈ Z c (x) = a := and z = az ∈ Z or c(z) = az ∈ Z ⊥ otherwise Intuitively, the value of the variable on the left–hand side is constant iff all operands are either constants in the code or known to be constants during execution. For a data–flow problem, the initial value will be a0 = ⊥: At the entry, no variable can be constant. Fig. 1 shows an example for a program, the associated abstractions, the equation system, and the MFP solution. This example also demonstrates why it is necessary to use the infinite lattice C: The solution contains the constant ‘5’ which is not found in the program. Our presentation of these notions differs slightly from the presentation found in text books. Typically, data–flow problems are already formulated using an explicit graph structure. However, we want to point out that this is not a necessity. Furthermore, it allows us to formulate and prove the correctness of our algorithm without reference to the classical one.
···
−2
−1
0
1
2
⊥
Fig. 1. Hasse diagram of C, ,
···
50
Markus Mohnen
Program I0 = x := 1 I1 = y := 2 I2 = z := 3 I3 = goto 8 I4 = r := y + z
Abstractionsa x/1 y/2 z/3 (identity) n r/
c(y)+c(z) c(y), c(z) ∈ Z ⊥
I5 = if x ≤ z goto 7 (identity) n I6 = r := z + y
r/
I7 = x := x + 1
x/
c(z)+c(y) c(y), c(z) ∈ Z
otherwise n⊥c(x)+1 c(x) ∈Z ⊥
otherwise
I8 = if x < 10 goto 4 (identity) a
otherwise
Equations s0 = a 0 s1 =![I0 !]C (s0 ) s2 =![I1 !]C (s1 ) s3 =![I2 !]C (s2 )
Solution x/⊥ y/⊥ z/⊥ x/1 y/⊥ z/⊥ x/1 y/2 z/⊥ x/1 y/2 z/3
s4 =![I8 !]C (s8 )
x/⊥ y/2 z/3 r/⊥
s5 =![I4 !]C (s4 )
x/⊥ y/2 z/3 r/5
s6 =![I5 !]C (s5 )
x/⊥ y/2 z/3 r/5
r/⊥ r/⊥ r/⊥ r/⊥
s7 =![I5 !]C (s5 )![I6 !]C (s6 ) x/⊥ y/2 z/3 r/5 s8 =![I3 !]C (s3 )![I7 !]C (s7 ) x/⊥ y/2 z/3 r/5
For each abstraction only the modification x/y as abbreviation for c → c[x/y] is given.
Fig. 2. Example for data–flow problem
The approach described so far can be generalised in two dimensions: Firstly, changing to results in existential data–flow problems, in contrast to universal data–flow problems: The intuition is that a property holds at a point if there is a single path starting at the point such that the property holds on this path. For existential data–flow problems, the least fixed point is computed instead of the greatest fixed point. Secondly, we can change predecessors predP to successors succP : {0, . . . , n} → P({0, . . . , n}) defined as i ∈ succP (j) ⇐⇒ j ∈ predP (i). The resulting class of data–flow problems are called backward problems (in contrast to forward problems), since the flow of information is opposite to the normal execution flow. Here, the abstract values are valid immediately after the corresponding instruction. Altogether, the resulting taxonomy has four cases. However, the algorithms for all the cases have the same general structure. Therefore, we will consider only the forward and universal setting.
3
Classical Iterative Basic-Block Based Algorithm
This section reviews the classical, graph–based approach to DFA. To make the data–flow of program explicit, we define two types of flow graphs: single instruction (SI) graphs and basic block (BB) graphs. For a program P = I0 . . . In , we define the SI graph SIG(P ) := ({I0 , . . . , In }, {(Ij , Ii ) | j ∈ predP (i)}, I0 ) with a node for each instruction, an edge from node Ij to node Ii iff j is predecessor of i, and root node I0 . Intuitively, the BB graph results from the SI graph by merging maximal sequences of straight–line code. Formally, we define the set of basic blocks as the unique partition of P : BB(P ) = {B0 , . . . , Bm } iff (a) Bj = Ij1 . . . Ijn with jk+1 = jk +1, (b) predP (j1 ) = {(j−1)n } or succP ((j−1)n ) = {j1 }, (c) |predP (jk )| = 1 for j1 < jk ≤ jn , and (d) Ijn +1 = I(j+1)1 , I01 = I0 , and Imn = In . The BB graph is defined as BBG(P ) := (BB(P ), {(Bj , Bi ) | jn ∈ predP (i1 )}, B0 ).
A Graph–Free Approach to Data–Flow Analysis
I0 I1 I2
I0 I1 B0 = I2 I3
I3 I4
B1 =
I4 I5
I5
B2 = I6
I6
B3 = I7
I7 I8
(a) SI graph
51
B4 = I8
(b) BB graph
Fig. 3. Examples for SI graph and BB graph
Example 2 (Constant Folding Propagation, Cont’d). In Fig. 3 we see the SI graph and the BB graph for the example program from the last section. Obviously, for a given flow graph G = (N, E, r), the usual notions of predecessors predG : N → P(N ) and successors succG : N → P(N ), defined as n ∈ predG (n ), n ∈ succG (n) : ⇐⇒ (n , n) ∈ E coincide with the corresponding notions for programs. For a given data–flow problem (P, L, ![.!], a0 ), an additional pre–processing step must be performed to extend the abstract semantics to basic blocks: We define ![.!] : Instr+ → (A → A) as ![I0 . . . In !] :=![In !] ◦ · · · ◦![I0 !]. The classical iterative algorithm for computing the MFP solution of a data– flow problem is shown in Fig. 4. In addition to the BB graph G it uses a working set W and an array a, which associates an abstract value with each node. The working set keeps all nodes which must be visited again. In each iteration a node is selected from the working set. At this level, we assume no specific strategy for the working set and consider this choice to be non–deterministic. By visiting all predecessors of this node, a new approximation is computed. If this approximation differs from the last approximation, the new one is used. In addition, all successors of the node are put in the working set. After termination of the main loop, the post–processing is done, which propagates the solution from the basic block level to the instruction level. Example 3 (Constant Folding Propagation, Cont’d). For the example from the last section, Table 1 shows a trace of the execution of the algorithm. Each line shows the state of working set W , the selected node B, and the array a[.] at
52
Markus Mohnen Input: Data–flow problem (P, L, ![.!], a0 ) where P = I0 . . . In , L = A, , Output: MFP solution s0 , . . . , sn ∈ A G = (BB(P ), E, B0 ) := BBG(P ) a[B0 ] := a0 for each B ∈ BB(P ) − B0 do a[B] := W := BB(P ) while W = ∅ do choose B ∈ W W := W − B new := a[B] for each B ∈ predG (B) do new := new![B !](a[B ]) if new = a[B] then a[B] := new; for each B ∈ succG (B) do W := W + B end end for each B ∈ BB(P ) do with B = Ik . . . Il do sk := a[B] for i := k + 1 to l do si :=![Ii−1 !](si−1 ) end end
Fig. 4. Classical iterative algorithm for computing MFP solution
the end of the main loop. To keep the example brief, we omitted all cells which did not change w.r.t. the previous line and we have chosen the best selection of nodes. The resulting MFP solution is identical to the one in Fig. 1, of course. In an implementation, the non–deterministic structure of the working set must be implemented in a deterministic way. However, both the classical algorithm described above and the new algorithm, which we describe in the next section, based on the concept of working sets. Therefore, we continue to assume that the working set is non–deterministic.
4
New Execution Based Algorithm
The new algorithm for computing the MFP solution (see Fig. 5) of a given data–flow problem is graph–free. The underlying idea is to give the program a more active role: The program itself executes on the abstract values. The program counter variable pc always holds the currently executing instruction. The execution of this instruction affects the abstract values for all succeeding instructions and it is propagated iff it makes a change. Here we see another difference w.r.t. the classical algorithm: While the pc in our algorithm identifies the instruction causing a change, the current node n in the classical algorithm
A Graph–Free Approach to Data–Flow Analysis
53
Table 1. Example Execution of classical iterative algorithm W B a[B0 ] a[B1 ] a[B2 ] a[B3 ] a[B4 ] {B1 , B2 , B3 , B4 } B0 x/⊥ y/⊥ x/ y/ x/ y/ x/ y/ x/ y/ z/⊥ r/⊥ z/ r/ z/ r/ z/ r/ z/ r/ {B1 , B2 , B3 } B4 x/1 y/2 z/3 r/⊥ {B2 , B3 } B1 x/1 y/2 z/3 r/5 {B3 } B2 x/1 y/2 z/3 r/5 {B4 } B3 x/2 y/2 z/3 r/5 {B1 } B4 x/⊥ y/2 z/3 r/⊥ {B2 } B1 x/⊥ y/2 z/3 r/5 {B3 } B2 x/⊥ y/2 z/3 r/5 ∅ B3 x/⊥ y/2 z/3 r/5
identifies the point where a change is cumulated. Note that the algorithm checks whether or not the instruction makes a change by the condition new < spc which is equivalent to new spc = new and new = spc . Obviously, the execution cannot be deterministic: On the level of abstract values there is no way to determine which branch to follow at conditional jumps. Therefore, we consider both branches here. Consequently, we use a working set of program counters, just like the classical algorithm uses a working set of graph nodes. However, the new algorithm uses the working set in a more modest way that the classical: While the classical one chooses a new node from the working set in each iteration, the new one follows one path of computation as long as changes occur and the path does not reach the end of the program. This is done in the inner repeat/until loop. Only if this path terminates, elements are chosen from the working set in the outer while loop. In addition, the new algorithm tries to keep the working set as small as possible during execution of a path: Note that the instruction W := W − pc is placed inside the inner loop. Hence, even execution of a path may cause the working set to shrink. In comparison to the classical algorithm, our approach has the following advantages: – It uses less memory: There is neither a graph to store the possible control flows in the program nor an associative array needed to store the abstract values at the basic block level.
54
Markus Mohnen Input: Data–flow problem (P, L, ![.!], a0 ) where P = I0 . . . In , L = A, , Output: MFP solution s0 , . . . , sn ∈ A s0 := a0 for i := 1 to n do si := W := {0, . . . , n} while W = ∅ do choose pc ∈ W repeat W := W − pc new :=![Ipc !](spc ) if Ipc = (goto l) then pc := l else pc := pc + 1 if Ipc = (if ψ goto l) and new < sl then W := W + l sl := new end end if new < spc then spc := new pc := pc else pc := n + 1 end until pc = n + 1 end
Fig. 5. New execution algorithm for computing MFP solution
– The data locality is better. At a node, the classical algorithm visits all predecessors and potentially all successors. Since these nodes will typically be scattered in memory, the access to the abstract values associated with them will often cause data cache misses. In contrast, our algorithm only visits a node and potentially its successors. Typically, one of the successors is the next instruction. Since the abstract values are arranged in an array, the abstract value associated with the next instruction is the next element in the array. Here, the likelihood of cache hits is large. Recent studies show that such small differences in data layout can cause large differences in performance on modern system architectures [CHL99, CDL99]. – There is no need for pre–processing by finding the abstract semantics of a basic block ![I0 . . . In !] :=![In !] ◦ · · · ◦![I0 !]. – There is no need for a post–processing stage, which propagates the solution from the basic block level to the instruction level. Theorem 1 (Termination). The algorithm in Fig. 5 terminates for all inputs. Proof. During each execution of the inner loop at least one 0 ≤ i ≤ n exists such that value of the variable si decreases w.r.t. the underlying partial order of the lattice L. Since L only has finite chains, this can happen only finitely many times. Hence, the inner loop always terminates.
A Graph–Free Approach to Data–Flow Analysis
55
Furthermore, the working set grows iff a conditional jump is encountered and the corresponding value sl decreases. Just like above, this can happen only finitely many times. Hence, there is an upper bound for the size of the working set. In addition, during each execution of the outer loop, the working set shrinks at least by one element, the one chosen in the outer loop. Hence, the outer loop always terminates. Theorem 2 (Correctness). After termination of the algorithm in Fig. 5, the values of the variables s0 , . . . , sn are the MFP solution of the given data–flow problem. Proof. To prove correctness, we can obviously consider a modified version of the algorithm, where the inner loop is removed and nodes are selected from the working set in each iteration. In this setting, no program point will be ignored forever. Hence, we can use the results from [GKL+ 94]: The selection of program point is a fair strategy and the correctness of our algorithm directly follows from the theorem on chaotic fixed point iterations. To do so, we have to validate one more premise of the theorem: We have to show that the algorithm computes si = j∈predP (i) ![Ij !](sj ) for each program point 0 ≤ i ≤ n. The algorithm can change si iff it visits a program point pc with pc ∈ predP (i). Let s be the value of si before the loop and s be the value after the loop. If we can show that s = s![Ipc !](spc ), we know that the algorithm computes the meet over all predecessors by iteratively computing the pairwise meet. To show that, we distinguish two cases: 1. If ![Ipc !](spc ) = new < s then s = new = s![Ipc !](spc ). 2. Otherwise, we know that ![Ipc !](spc ) = new ≥ s since ![.!] is monotone and the initial value of s is the top element. Hence we also have s = s = s![Ipc !](spc ). Example 4 (Constant Folding Propagation, Cont’d). Table 2 shows an trace of the execution of the new algorithm for the constant folding propagation example. Each line shows the state of the working set and the approximations at the end of the inner loop, and the values of the program counter pc at the beginning and the end of the inner loop (written in the column pcs in the form begin/end). During this execution, the algorithm loads the value of pc only three times from the working set: Once at the beginning and twice after reaching the end of the program (pcs = 8/9). The adaption of the execution algorithm for the other three cases of the taxonomy of data–flow problems described at the end of Section 2 is straightforward: (a) Existential problems can simply be handled by replacing < by >, and (b) backward problems require a simple pre–processing which inserts new pseudo instructions to connect jump targets with the corresponding jump instructions.
5
Experimental Results
To validate the benefits of our approach, we studied the performance of the new algorithm in competition with the classical one, both in terms of memory
56
Markus Mohnen
Table 2. Example execution of new algorithm W pcs s0 s1 {1, . . . , 8} 0/1 x/⊥ y/⊥ x/1 y/ z/⊥ r/⊥ z/ r/ {2, . . . , 8} 1/2 {3, . . . , 8} 2/3 {4, . . . , 8} 3/8 {4, . . . , 7} 8/9
s2 s3 s4 s5 s6 s7 s8 x/ y/ x/ y/ x/ y/ x/ y/ x/ y/ x/ y/ x/ y/ z/ r/ z/ r/ z/ r/ z/ r/ z/ r/ z/ r/ z/ r/ x/1 y/2 z/⊥ r/⊥ x/1 y/2 z/3 r/⊥ x/1 y/2 z/3 r/⊥
{5, . . . , 7} 4/5 {6, 7}
5/6
{7} ∅
6/7 7/8
{4}
8/9
∅
4/5
{7}
5/6
{7} ∅
6/7 7/8
∅
8/9
x/1 y/2 z/3 r/5 x/1 y/2 x/1 y/2 z/3 r/5 z/3 r/5 x/2 y/2 z/3 r/5 x/⊥ y/2 z/3 r/⊥ x/⊥ y/2 z/3 r/5 x/⊥ y/2 x/⊥ y/2 z/3 r/5 z/3 r/5 x/⊥ y/2 z/3 r/5
usage and runtimes. Prior to the presentation of the results, we discuss the experimental setting in more detail. We have implemented the classical BB algorithm and our new execution algorithm for full Java Virtual Machine (JVM) code [LY97]. This decision was taken in view of the following reasons: 1. As already mentioned, we see the JVM as a natural target environment for our execution–based algorithm, since it already contains an execution environment and is sensitive to high memory overhead. 2. Except for native code compilers for Java [GJS96], all compilers generate the same JVM code as target code. Consequently, we get realistic samples independent of a specific compiler. 3. Java programs are distributed as JVM code, often available for free on the internet.
A Graph–Free Approach to Data–Flow Analysis
57
Although we omitted procedure/method calls from our model, we can handle full JVM code. For intraprocedural analysis, we assume the result of method invocations to be the top element of the lattice. All these aspects allowed us to collect a large repository of JVM code with little effort. In addition to active search, we established a web site for donations of class files at http://www-i2.informatik.rwth-aachen.de/~mohnen/ CLASSDONATE/. So far, we have collected 15,339 classes with a total of 98,947 methods. This large set of samples covers a wide range of applications, applets, and APIs. To name a few, it contains the complete JDK runtime environment (including AWT and Swing), the compiler generator ANTLR, the Byte Code Engineering Library, and the knowledge-based system Prot´eg´e. The classes were compiled by a variety of compilers: javac (Sun) in different version, jikes (IBM), CodeWarrior (Metrowerks), and JBuilder (Borland). In some cases, the class files were compiled to JVM code from other languages than Java, for instance from Ada using Jgnat. In contrast to a hand–selected suite of benchmarks like SPECjvm98 [SPE], we do not impose any restrictions on the samples in the set: The samples may contains errors or even might not be working at all. In our opinion, this allows a better estimation of the “average case” a data–flow analyser must face in practice. Altogther, we consider our experiments suitable for estimating the benefits and drawbacks of our method.
import de.fub.bytecode.generic.*; import Domains.*; public interface JVMAbstraction { public Lattice getLattice(); public Element getInitialValue(InstructionHandle ih); public Function getAbstract(InstructionHandle ih); }
Fig. 6. Interface JVMAbstraction However, we did not integrate our experiment in a JVM. Doing so would have fixed the experiment to a specific architecture since the JVM implementation depends on it. Therefore, we implemented the classical BB algorithm and our new execution algorithm in Java, using the Byte Code Engineering Library [BD98] for accessing JVM class files. The implementation directly follows the notions defined in Section 2: We used the interface concept of Java to model the concepts of lattices, (JVM) abstractions, and data–flow problems. For instance, Fig. 6 shows the essential parts of the interface JVMAbstraction which models JVM abstractions. Consequently, the algorithms do not depend on specific data–flow problems. In contrast, our approach allows to model any data–flow problem simply by providing a Java class which implements the interface JVMAbstraction.
58
Markus Mohnen
20.
40.
60.
80.
Memory 100. Reduction %
Fig. 7. Histogram of memory reduction For the experiment, we implemented constant folding propagation, as described in the previous sections. All experiment were done on a system with Pentium III at 750 Mhz, 256 MB main memory running under Linux 2.2.16 and Sun JDK 1.2.2. For each of the 98,947 JVM methods of the repository, we measured memory usage and runtimes of both our algorithm and the classical algorithm. The working set was implemented as a stack. Memory improvement. Given the number of bytes mX allocated by our algorithm and the number of bytes mC allocated by the classical algorithm, we compute the memory reduction as the percentage mX /mC ∗ 100. In the resulting distribution, we found a maximal reduction of 7.28%, a minimal reduction of 74.61%, and an average reduction of 30.83%. Moreover, the median1 is 31.28%, which is very close to the average. Hence, our algorithm always reduces the amount of memory and reached improvements upto less than a tenth! In the average case, the reduction is about a third. Fig. 7 shows a histogram of the distribution. A study of the relation of number of instructions and memory reduction does not reveal a relation between those values. In Fig. 8(a) each point represents a method: The coordinates are the number of instructions on the horizontal axis and memory reduction of the vertical axis. We have restricted the plot to the interesting range up to 1,000 instructions: While the sample set contains methods with up to 32,768 instructions, the average of instructions per method is only 40.3546 and the median is only 11. Obviously, object–orientation has a measurable impact on the structure of program. Surprisingly, there is a relation between the amount of reduction caused by BBs and memory reduction. One might expect that the classical algorithm is better for higher amounts of reduction cause by BBs. However, this turns out to 1
The median (or central value) of a distribution is the value with the property that one half of the elements of the distribution is less or equal and the other half is greater or equal.
A Graph–Free Approach to Data–Flow Analysis
(a) Memory reduction vs. number of instructions
59
(b) Memory reduction vs. basic block reduction
Fig. 8. Memory reduction of new algorithm
be a wrong: Fig. 8(b) shows that the new algorithm reduces the memory even more for higher BB reductions. Runtimes. For the study of runtimes, we use the speedup caused by the use of the classical algorithm: If tC is the runtime of the classical algorithm and tX is the runtime of our algorithm, we consider tC /tX to be the speedup. The distribution of speedups turned out to be a big surprise: Speedups vary from 291.2 down to 0.015, but the mean is 1.62, median is 1.33, and variance is only 7.49! Hence, for the majority of methods our algorithm performs as well as the BB algorithm. Fig. 9 shows a histogram of the interesting area of the distribution. Again, relating speedup on one hand and number of instructions Fig. 10(a) on the other hand did not reveal a significant correlation. In addition, and not surprisingly, the speedup is higher for better BB reduction Fig. 10(b) .
BB Speedup 0.5
1.
1.5
2.
2.5
3.
Fig. 9. Histogram of BB speedup
60
Markus Mohnen
(a) Speedup vs. number of instructions
(b) Speedup vs. basic block reduction
Fig. 10. Speedup of classical algorithm
6
Conclusions and Future Work
We have shown that data–flow analysis can be done without explicit graph structure. Our new algorithm for computing the MFP solution of a data–flow problem is based on the idea of the program executing on the abstract values. The advantages resulting from the approach are less memory use, better data locality, and no need for pre–processing or post–processing stages. We validated these expectation by applying a test implementation to a large set of sample. It turned out that while the runtimes are almost identical, our approach always saves between a third and 9/10 of the memory used by the classical algorithm. In the average case, it saves two thirds of the memory used by the classical algorithm. The algorithm is very easy to implement in settings where there is already a machinery for execution of programs available, for instance in Java Virtual Machines. In addition, the absence of the graph makes the algorithm easier to implement. In the presence of full JVM code, implementing BB graphs turned out to be trickier than expected. In fact, after having implemented both approaches, errors in the implementation of the BB graphs were revealed by the correct results of the new algorithm.
References [AH87]
S. Abramsky and C. Hankin. An Introduction to Abstract Interpretation. In S. Abramsky and C. Hankin, editors, Abstract Interpretation of Declarative Languages, chapter 1, pages 63–102. Ellis Horwood, 1987. 48 [ASU86] A.V. Ahos, R. Sethi, and J.D. Ullman. Compilers: Principles, Techniques, and Tools. Addison Wesley, 1986. 46 [BD98] B. Bokowski and M. Dahm. Byte Code Engineering. In C. H. Cap, editor, Java-Informations-Tage (JIT), Informatik Aktuell. Springer–Verlag, 1998. See also at http://bcel.sourceforge.net/. 57
A Graph–Free Approach to Data–Flow Analysis [Bla98]
61
B. Blanchet. Escape Analysis: Correctness Proof, Implementation and Experimental Results. In Proceedings of the 25th Symposium on Principles of Programming Languages (POPL). ACM, January 1998. 47 [Bla99] B. Blanchet. Escape Analysis for Object Oriented Languages: Application to Java. In Proceedings of the 14th Conference on Object-Oriented Programming, Systems, Languages, and Applications (OOPSLA), volume 34, 10 of ACM SIGPLAN Notices, pages 20–34. ACM, 1999. 47 [CC77] P. Cousot and R. Cousot. Abstract Interpretation: A Unified Lattice Model for Static Analysis of Programs by Construction or Approximation of Fixed Points. In Proceedings of the 4th Symposium on Principles of Programming Languages (POPL), pages 238–252. ACM, January 1977. 48 [CDL99] T. M. Chilimbi, B. Davidson, and J. R. Larus. Cache-conscious structure definition. In PLDI’99 [PLD99], pages 13–24. 54 [CHL99] T. M. Chilimbi, M. D. Hill, and J. R. Larus. Cache-Conscious Structure Layout. In PLDI’99 [PLD99], pages 1–12. 54 [Deu97] A. Deutsch. On the Complexity of Escape Analysis. In Proceedings of the 24th Symposium on Principles of Programming Languages (POPL), pages 358–371. ACM, January 1997. 47 [GJS96] J. Gosling, B. Joy, and G. Steele. The Java Language Specification. The Java Series. Addison Wesley, 1996. 56 uttgen, O. R¨ uthing, and B. Steffen. Chaotic Fixed [GKL+ 94] A. Geser, J. Knoop, G. L¨ Point Iterations. Technical Report MIP-9403, Fakult¨ at f¨ ur Mathematik und Informatik, University of Passau, 1994. 55 [KKS98] J. Knoop, D. Kosch¨ utzki, and B. Steffen. Basic-Block Graphs: Living Dinosaurs? In K. Koskimies, editor, Proceedings of the 7th International Conference on Compiler Construction (CC), number 1383 in Lecture Notes in Computer Science, pages 65–79. Springer–Verlag, 1998. 46 [LY97] T. Lindholm and F. Yellin. The Java Virtual Machine Specification. The Java Series. Addison Wesley, 1997. 46, 47, 56 [MJ81] S. S. Muchnick and N. D. Jones. Program Flow Analysis: Theory and Applications. Prentice–Hall, 1981. 46 [Moh97] M. Mohnen. Optimising the Memory Management of Higher–Order Functional Programs. Technical Report AIB-97-13, RWTH Aachen, 1997. PhD Thesis. 47 [Muc97] S. S. Muchnick. Advanced Compiler Design and Implementation. Morgan Kaufmann Publishers, 1997. 46 [PLD99] Proceedings of the ACM SIGPLAN ’99 Conference on Programming Language Design and Implementation (PLDI), SIGPLAN Notices 34(5). ACM, 1999. 61 [SPE] Standard Performance Evaluation Corporation. SPECjvm98 documentation, Relase 1.01. Online version at http://www.spec.org/osg/jvm98/jvm98/doc/. 57
A Representation for Bit Section Based Analysis and Optimization Rajiv Gupta1 , Eduard Mehofer2 , and Youtao Zhang1 1
Department of Computer Science, The University of Arizona Tucson, Arizona 2 Institute for Software Science, University of Vienna Vienna, Austria
Abstract. Programs manipulating data at subword level are growing in number and importance. Examples are programs running on network processors, media processors, or general purpose processors with media extensions. In addition data compression techniques which are vital for embedded system applications result in code operating on subword level as well. Performing analysis on word level, however, is too coarse grain missing opportunities for optimizations. In this paper we introduce a novel program representation which allows reasoning at subword level. This is achieved by making accesses to subwords explicit. First in a local phase statements are analyzed and accesses at subword level identified. Then in a global phase the control-flow is taken into account and the accesses are related to one another. As a result various traditional analyses can be performed on our representation at subword level very easily. We discuss the algorithms for constructing the program representation in detail and illustrate their application with examples.
1
Introduction
Programs that manipulate data at subword level are growing in number and importance. The need to operate upon subword data arises if multiple data items are packed together into a single word of memory. The packing may be a characteristic of the application domain or it may be carried out automatically by the compiler. We have identified the following categories of applications. Network processors are specialized processors that are being designed to efficiently manipulate packets [5]. Since a packet is a stream of bits the individual fields in the packet get mapped to subword entities within a memory location or may even be spread across multiple locations. Media processors are special purpose processors to process media data (e.g., TigerSHARC [3]) as well as general purpose processors with multimedia extensions (e.g., Intel’s MMX [1,6]). The narrow width of media data is exploited by
Supported by DARPA PAC/C Award. F29601-00-1-0183 and NSF grants CCR0105355, CCR-0096122, EIA-9806525, and EIA-0080123 to the Univ. of Arizona.
R. N. Horspool (Ed.): CC 2002, LNCS 2304, pp. 62–77, 2002. c Springer-Verlag Berlin Heidelberg 2002
A Representation for Bit Section Based Analysis and Optimization
63
packing multiple data items in a single word and supporting instructions that are able to exploit subword parallelism. Data compression transformations reduce the data memory footprint of the program [2,9]. After data compression transformations have been applied, the resulting code operates on subword entities. Program analysis, which is the basis of optimization and code generation phases, is a challenging task for above programs since we need to reason about entities at subword level. Moreover, accesses at subword level are expressed in C (commonly used language in those application domains) by means of rather complex mask and shift operations. In this paper we introduce a novel program representation that enables reasoning about subword entities corresponding to bit sections (a bit section is a sequence of consecutive bits within a word). This is made possible by explicitly expressing manipulation of bit sections and relating the flow of values among bit sections. We present algorithms for constructing this representation. The key steps in building our representation are as follows: – By locally examining the bit operations in an expression appearing on the right hand side of an assignment, we identify the bit sections of interest. In particular, the word corresponding to the variable on the left hand side is split into a number of bit sections such that adjacent bit sections are modified differently by the assignment. The assignment statement is replaced by multiple bit section assignments. – By carrying out global analysis, explicit relationships are established among different bit sections belonging to the same variable. These relationships are expressed by introducing split and combine nodes. A split node takes a larger bit section and replaces it by multiple smaller bit sections and a combine node takes multiple adjacent bit sections and replaces them by a single larger bit section. The above representation is appropriate for reasoning about bit sections. For example, the flow of values among the bit sections can be easily traced in this representation resulting in definition-use chains at the bit section level. Moreover, since our representation makes accesses at subword level explicit, processors with special instructions for packet-level addressing can be supported easily and efficiently by the code generator and the costly mask and shift operations can be replaced. The remainder of the paper is organized as follows. In section 2 we describe our representation including its form and its important properties. In sections 3 and 4 we present the local and global phases of the algorithm used to construct the representation. And finally concluding remarks are given in section 5.
64
Rajiv Gupta et al.
2
The Representation
This section presents our representation for bit section based analyses and optimizations. Starting point for our extensions are programs modeled as directed control flow graphs (CFG) G = (N, E, entry, exit) with node set N including the unique entry and exit nodes and edge set E. For the ease of presentation we assume that the nodes represent statements rather than basic blocks1 . The construction of our representation is driven by assignment statements of the form v = t whereby the right hand side term t contains bit operations only, i.e. & (and), | (or), not (not), > (shift right). Since the term on the right hand side of such an assignment can be arbitrarily long and intricate and since our goal is to replace those assignments by a sequence of simplified assignments, we call them complex assignments. Essentially our representation is based on two transformations performed on the CFG. First we partition the original program variables into bit sections of interest. The bit sections of interest are identified locally by examining the usage of these bit sections in a complex assignment. Only complex assignments which are formed using bit operations are processed by this phase because partitioning is guided by the special semantics of bit operations. Other assignments are not partitioned since no useful additional information can be exposed in this way. Hence, in the remainder of the discussion, only complex assignments are considered. Second we relate definitions and uses of bit sections belonging to the same program variable using global analysis. The required program representation is obtained by making the outcomes of the above steps explicit in the CFG. In the remainder of this section, we illustrate the effects of the above two steps and describe the resulting representation in detail. 2.1
Identifying Bit Sections of Interest
Definition 1 (Bit Section). Given a program variable v with the size of c bits, a bit section of v is denoted by vl..h (1 ≤ l ≤ h ≤ c) and refers to the sequence of bits l, l + 1, .., h − 1, h.2 The symbol := is used to denote a bit section assignment. In the following discussion, if nothing is said to the contrary, we assume for the ease of discussion that variables have a size of 32 bits. Partitioning a program variable. Given a complex assignment (v = t), the program variable v on the left hand side is partitioned into bit sections, if each of the resulting sections is updated differently from its neighboring bit sections by the term t on the right hand side of the complex assignment. In particular, the value of a bit section of the lhs variable v, say vl..h , can be specified in one of the following ways: 1 2
Handling basic blocks is straightforward. The definition includes 1-bit sections as well as whole variable sections.
A Representation for Bit Section Based Analysis and Optimization
65
– No Modification: The value of vl..h remains unchanged because it is assigned its own value. – Constant Assignment: vl..h is assigned a compile time constant. – Copy Assignment: The value of another bit section variable is copied into vl..h . – Expression Assignment: The value of vl..h is determined by an expression which is in general simpler than t. The partitioning of variable v is made explicit in the program representation by replacing the complex assignment by a series of bit section assignments. A consequence of this transformation is that operands used in t may also have to be partitioned into compatible bit sections. Properties. There are two important properties that will be observed by our choice of bit section partitions: 1. Non-overlapping sections. The sections resulting from such partitioning are non-overlapping for individual assignments. 2. Maximal sections. Each section is as large as needed to expose the semantic information that can be extracted from a given complex assignment. In other words, further partitioning will not provide us with any more information about the values stored in the individual bits. Example. Consider the complex assignment to variable a shown in Fig. 1. If we carefully examine this assignment, we observe that this complex assignment is equivalent to the bit section assignments shown below. Note that each bit section is updated differently from its neighboring sections. Bit sections a1..4 and a17..32 are set to 0, a5..8 is involved in a copy assignment, and a13..16 is not modified at all (we have placed the assignment below simply for clarity). Bit section a9..12 is computed using an expression which is simpler than the original expression. Finally, as a consequence of a’s partitioning, variable b must be partitioned into compatible bit sections as well.
Complex Assignment a = (a & 0xf f 00) | ((b & 0xf f ) >’s annotation var/1 : {[(l, h), s]} 0 : {[(32 − c, 32), 0]} and var/1 : {[(l − s + c, h − s + c), s − c]}, where (l , h ) = (l + s − c, h + s − c) ∩ (0, 32) 0 : {[(32 − c, 32), 0]} and 0 : {[(l, h), 0]} 0 : {[(l , h ), 0]}, where (l , h ) = (l − c, h − c) ∩ (0, 32) 0 : {[(l − c, 32) ∩ (0, 32), 0]} 0 : {[(l, 32), 0]}
2. Ensure all bits within a section are computed identically. Closer examination of bit sections of different operand variables that annotate a given node can reveal whether further splitting of these bit sections is required to ensure that each resulting bit section is computed by exactly one expression. The bit section var1 : {[(l1 , h1 ), s1 ]} is split by bit section var2 : {[(l2 , h2 ), s2 ]}, denoted by var1 /var2 , at a node in the expression tree by means of the following rule:
72
Rajiv Gupta et al.
var1 : {[(l1 , l2 + s2 − s1 , h2 + s2 − s1 , h1 ), s1 ]} if l1 + s1 < l2 + s2 < h2 + s2 < h1 + s1 var1 : {[(l1 , l2 + s2 − s1 , h1 ], s1 }
var1 : {[(l1 , h1 ), s1 ]} if l1 + s1 < l2 + s2 < h1 + s1 < h2 + s2 = var var2 : {[(l2 , h2 ), s2 ]} 1 : {[(l1 , h2 + s2 − s1 , h1 ), s1 ]} if l2 + s2 < l1 + s1 < h2 + s2 < h1 + s1 var1 : {[(l1 , h1 ), s1 ]} otherwise
The splitting is performed by considering every ordered pair of bit sections. As we can see, the above bit sectioning is performed to distinguish between bit sections which are computed differently by both bit sections var1 and var2 . More precisely, we distinguish a bit section which is computed from both var1 and var2 from one which is computed only from var1 . 3. Identify bit sections for the lhs variable. After steps 1 and 2, the annotations of the root node of the expression tree are used to identify the bit sections of the variable on the left hand side. Let us assume that the width of a word is 32 bits, then we split the initial bit section of the lhs variable varlhs : {[(0, 32), 0]} if parts are computed differently. More formally, new bit sections are obtained by a repeated evaluation of varlhs : section any : {[(l, h), s]} for each annotation any : {[(l, h), s]} at the root node of the rhs tree. II. Generating bit section assignments. In this step we generate the bit section assignments corresponding to the bit sections identified for a lhs variable of a complex assignment. Given a bit section vl+1..h , the expression which has to be assigned to vl+1..h is returned by the function call genexp((l, h), eroot), where eroot is the root node of the entire expression tree, i.e., for each bit section (l, h) for a lhs variable v we call vl+1..h := simplif y(genexp((l, h), eroot)). Function simplify is the last step in which trivial patterns like “a|0” or “a&1” are reduced to “a”. As shown in Fig. 5, genexp() traverses the expression examining the bit sections that annotate each node in order to find those that contribute to bits l + 1..h. If only one of the bit sections at a node contributes to bits l + 1..h, a traversal of the subtree is not required any more. In this case the operand is a sequence of h−l bits belonging to a variable or it consists of constant (0 or 1) bits. If multiple bit sections contribute to bits l + 1..h, then the operator represented by the current node is included in the expression and the subexpressions that are its operands are identified by recursively applying genexp() to the descendants.
A Representation for Bit Section Based Analysis and Optimization
73
genexp((l, h), e) { BS = φ f or each section any : [(el, eh), es] ∈ set of annotations of node e do if range (l, h) is contained in range (el + es, eh + es) then BS = BS ∪ {any : [(el, eh), es]} endif endf or if BS == {any : [(el, eh), es]} then return (”anyl−es+1..h−es ”) else let e.lchild and e.rchild be expression trees f or operands of e case e.op of e.op == ”not” : return (”not” genexp((l, h), e.lchild); e.op == ” > c” : return(genexp((l + c, h + c), e.lchild); e.op == ”&” : return(genexp((l, h), e.lchild) ”&” genexp((l, h), e.rchild); e.op == ”|” : return(genexp((l, h), e.lchild) ”|” genexp((l, h), e.rchild)); end case endif }
Fig. 5. Generating Bit Section Assignments Step 1: 1:{[(8,16),0], 0:{[(0,8),0], [(16,32),0]}
Step 1: a:{[(0,32),0]} a
b
0xff00
Step 1: a:{[(8,16),0]} 0:{[(0,8),0], [(16,32),0]}
Step 1: b:{[(0,32),0]}
&
e
Step 1: 1:{[(0,8),0]} 0:{[(8,32),0]} 0xff
Step 1: b:{[(0,8),0]} 2
0:{[(8,32),0]}
4
&
Step 1: b:{[(0,8),4]} 0:{[(0,4),0],
1 and p.no_of_successors > 1 then insert a new block n between p and b else n ← p for each φ-function phi of b do i ← new RegMove(phi.opd(p)) // the φoperand corresponding to p phi.opd(p) ← i append i to n join i with phi // see Section 4.4
Linear Scan Register Allocation in the Context of SSA Form and Register Constraints
4.2
237
Numbering the Instructions
After moves have been inserted for φ-operands the instructions have to be numbered consecutively. In order to do that we traverse all basic blocks in topological order so that a block b is only visited after all its predecessors that have forward branches to b have been visited. Fig. 10 shows some valid visit sequences.
1 2
1 3
3
4
2 4
1
1
2
2
3
4
4
3
Fig. 10. Valid visit sequences of blocks for instruction numbering
4.3
Computing Live Intervals
In SSA form there is only one assignment to every variable. This assignment marks the beginning of the variable’s lifetime. The variable lives in all paths from its definition to its last use. For every block b and every variable v we compute a range rv,b that denotes the live range of v in b as shown in Fig. 11. If v is live at the end of b it must have been defined either in b or in some predecessor block p. If v was defined in p then rv,b begins at b.first and ends after b.last. If it was defined in b then rv,b begins at the instruction v and ends after b.last. If v is not live at the end of b but is used in b then rv,b begins as described above and ends at the last use of v in b. The last use of a variable is detected using the live sets: the instructions of b are traversed in reverse order; if a variable v is used at instruction i but is not in the live set at the end of i then i is the last use of v. live: {v}
live: {}
live: {v}
live: {}
first: ... ... ... ... ... last: ... live: {v}
first: ... ... v: ... ... ... last: ... live: {v}
first: ... ... ... i: v ... ... last: ... live: {}
first: ... v: ... ... i: v ... ... last: ... live: {}
rv,b: [first, last+1[
rv,b: [v, last+1[
rv,b: [first, i[
rv,b: [v, i[
Fig. 11. Computation of the live range rv,b of a variable v in block b
The live interval of a φ-function i in block b does not start at i but at the first ordinary instruction in this block (b.first). This avoids undesired conflicts between the φfunctions of a block. It is an invariant of our algorithm that the defining instruction of a φ-function never appears in a live interval. The algorithm ADDRANGE(i, b, end) computes the range ri,b of instruction i in block b (according to Fig. 11) assuming that we already know that i ends living at the instruction with the number end. It then adds the range to the live interval of i.
238
Hanspeter Mössenböck and Michael Pfeiffer
ADDRANGE(i: Instruction; b: Block; end: integer) if b.first.n • i.n • b.last.n then range ← [i.n, end[ else range ← [b.first.n, end[ add range to interval[i.n] // merging adjacent ranges If possible, adjacent ranges of the same live interval are merged. For example, the ranges [1,3[, [3,7[ are merged into a single range [1,7[. The algorithm BUILDINTERVALS() traverses the control flow graph in an arbitrary order, finds out which values are live at the end of every block, and computes the ranges for these values as described above. BuildIntervals() for each block b do live ← {} for each successor s of b do live ← live ∪ s.live for each φ-function phi in s do live ← live – {phi} ∪ {phi.opd(b)} for each instruction i in live do ADDRANGE(i, b, b.last.n+1) for all instructions i in b in reverse order do live ← live – {i} for each operand opd of i do if opd ∉ live then live ← live ∪ {opd} ADDRANGE(opd, b, i.n)
Fig. 12 shows a sample program in source code and in intermediate representation with a φ-function for the value d and corresponding move instructions in the predecessor blocks. Fig. 13 shows the live intervals that are computed for this program by BUILDINTERVALS(). Note that the live intervals of i2 and i11 exclude instruction 11 since φ-functions never appear in live intervals. a = ... b = ... ... = a c=b d = ... ... = c e = ... ... = d ... = e
1: i1 = ... 2: i2 = ... d = ... ... = a
3: i3 = ... i1 4: i4 = ... i2 5: i5 = ... 6: i6 = ... i4 7: i7 = i5
8: i8 = ... 9: i9 = ... i1 10: i10 = i8
11: i11 = φ(i7, i10) 12: i12 = ... 13: i13 = i2 + i11 14: i14 = ...i12
Fig. 12. Sample program in source code and in intermediate representation
Linear Scan Register Allocation in the Context of SSA Form and Register Constraints
239
1 2 3 4 5 6 7 8 9 10 11 12 13 14 i1: i2: i4: i5: i7: i8: i10: i11: i12:
[1,3[, [8,9[ [2,11[, [12,13[ [4,6[ [5,7[ [7,8[ [8,10[ [10,11[ [12,13[ [12,14[ Fig. 13. Live Intervals computed from the program in Fig. 12
4.4
Joining Values
Sometimes we want that two values go into the same register, for example: • a φ-function and its operands (so that the φ-function can be eliminated); • the left-hand and right-hand sides of register moves (so that the move can be eliminated); • the first operand y and the result x of a two-address instruction x = y op z as it is required by the Intel x86 architecture. If the live intervals of the two values do not overlap we can join them, i.e. we merge their intervals so that the register allocator assigns the same register to them. This is also called coalescing ([2]). Note that coalescing leads to longer intervals possibly introducing additional conflicts that force more values into memory. Currently we do not try to minimize such conflicts although it could be done as described for example in [2]. A group of joined values is represented by only one of those values, its representative, using a union-find algorithm ([12]). Every instruction i has a field i.join, which points to its representative. Initially, i.join = i for all instructions i. If we have three values, a, b, and c, and if we join b with c, and then a with b we get a group with c as its representative as shown in Fig. 14.
a
a.join c
b Fig. 14. A group of four joined values with c as its representative
Taking into account that certain values have to be in specific registers we can join two values x and y only if they are compatible, i.e. if • both do not have to be in specific registers, or • both have to be in the same specific register, or • x must be in a specific register and the interval of y does not overlap any other interval to which x.reg has been assigned (or vice versa). More formally: • x.reg ≥ 0 ∧ ¬ (∃ interval iv: iv.reg = x.reg & interval[y.n] overlaps iv) ∨ • y.reg ≥ 0 ∧ ¬ (∃ interval iv: iv.reg = y.reg & interval[x.n] overlaps iv)
240
Hanspeter Mössenböck and Michael Pfeiffer
The algorithm JOIN(x, y) joins the two values x and y if they are compatible: JOIN(x, y: Instruction) i ← interval[REP(x).n] j ← interval[REP(y).n] if i ∩ j = {} and x and y are compatible then interval[REP(y).n] ← i ∪ j drop interval[REP(x).n] x.join ← REP(y) REP(x: Instruction): Instruction if x.join = x then return x else return REP(x.join) If we look at the program in Fig. 12 we can join the values 11, 7 and 10 (the φfunction and its operands) as well as 5 with 7 and 8 with 10 (the left- and right-hand sides of the register moves). The resulting intervals are shown in Fig. 15. The live intervals are now in a form that can be used for linear scan register allocation. This will be described in the next section. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 i1: i2: i4: i5,7,8,10,11: i12:
[1,3[, [8,9[ [2,11[, [12,13[ [4,6[ [5,11[, [12,13[ [12,14[
Fig. 15. Live intervals of Fig. 13 after join operations
5
The Linear Scan Algorithm
The register allocator has to map an unbounded number of virtual registers to a small set of physical registers. If a value cannot be mapped to a register it is assigned to a memory location. Many instructions of the Intel x86 allow memory operands so there is a good chance that this value never has to be loaded into a register. If it has to be in a register, however, we load it into a scratch register (one scratch register is excluded from register allocation). If an instruction needs more than one scratch register the code generator spills one of the registers and uses it as a temporary scratch register. When the spilled value is needed again the code generator reloads it into the same register as before. Note that spilling instructions are emitted by the code generator and not by the register allocator, which only decides if a value should reside in a register or in memory. The register allocator assumes that all live intervals of a method are sorted in the order of increasing start points. It makes the first interval the current interval (cur) and divides the remaining intervals into the following four sets: • • • •
unhandled set: all intervals that start after cur.beg; handled set: all intervals that ended before cur.beg or were spilled (see below); active set: all intervals where one of their ranges overlaps cur.beg; inactive set: all intervals where cur.beg falls into one of their holes.
Linear Scan Register Allocation in the Context of SSA Form and Register Constraints
241
Throughout register allocation the following invariants hold: Registers assigned to intervals in the handled set are free; registers assigned to intervals in the active set are not free; a register assigned to an interval i in the inactive set is either free or occupied by a currently active interval j that does not overlap i (i.e. fully lies in a hole of i). When i becomes active again, j already ended so that i can reclaim its register. The algorithm LINEARSCAN() repeatedly picks the first interval cur from unhandled updating the sets active, inactive and handled appropriately. LinearScan() unhandled ← all intervals in increasing order of their start points active ← {}; inactive ← {}; handled ← {} free ← set of available registers while unhandled • {} do cur ← pick and remove the first interval from unhandled //----- check for active intervals that expired for each interval i in active do if i ends before cur.beg then move i to handled and add i.reg to free else if i does not overlap cur.beg then move i to inactive and add i.reg to free //----- check for inactive intervals that expired or become reactivated for each interval i in inactive do if i ends before cur.beg then move i to handled else if i overlaps cur.beg then move i to active and remove i.reg from free //----- collect available registers in f f ← free for each interval i in inactive that overlaps cur do f ← f – {i.reg} for each fixed interval i in unhandled that overlaps cur do f ← f – {i.reg} //----- select a register from f if f = {} then ASSIGNMEMLOC(cur) // see below else if cur.reg < 0 then cur.reg ← any register in f free ← free – {cur.reg} move cur to active
If we cannot find a free register for cur we assign a memory location to either cur or to any of the other currently active or inactive intervals, whichever has a lower weight. The weights are computed from the accesses to the intervals weighted by the nesting level in which the accesses occur. Here is the algorithm:
242
Hanspeter Mössenböck and Michael Pfeiffer
ASSIGNMEMLOC(cur: Interval) for all registers r do w[r] ← 0 // clear register weights for all intervals i in active, inactive and (fixed) unhandled do if i overlaps cur then w[i.reg] ← w[i.reg] + i.weight // if fixed i.weight = • find r such that w[r] is a minimum if cur.weight < w[r] then assign a memory location to cur and move cur to handled else // assign memory locations to the intervals occupied by r move all active or inactive intervals to which r was assigned to handled assign memory locations to them cur.reg ← r move cur to active
Table 1 shows how LINEARSCAN() works through the intervals of Fig. 15 assuming that we have 2 registers available. The weights of the intervals can be computed from the accesses to values (see Fig. 12) and are as follows: i1:3, i2:3, i4:2, i5:7, i12:2 (accesses in a φ-function are neglected). Table 1. Simulation of LINEARSCAN() for the intervals of Fig. 15
cur action initialize 1 assign r1 to interval 1 2 assign r2 to interval 2 4 move interval 1 to inactive assign r1 to interval 4 5 put interval 2 into memoy assign r2 to interval 5 12 move int. 1 and 4 to handled assign r1 to interval 12
free r1, r2 r2 r1 r2 r1 -
unhandled 1, 2, 4, 5, 12 2, 4, 5, 12 4, 5, 12 5, 12 5, 12 12 12 -
active 1r1 1r1, 2r2 2r2 2r2, 4r1 4r1 4r1, 5r2 5r2 5r2, 12r1
inactive 1r1 1r1 1r1 1r1 -
handled 2m 2m 1r1, 2m, 4r1 1r1, 2m, 4r1
Interval 2 was put into memory because its weight (3) is less than the cumulated weights of intervals 1 and 4 that occupy the same register at that time (weight = 5) and of the current interval 5 (weight = 7). Fig. 16 shows the result of the register allocation for Fig. 15. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 i1: i2: i4: i5: i12:
r1 memory r1 r2 r1
Fig. 16. Result of the register allocation with 2 available registers
Linear Scan Register Allocation in the Context of SSA Form and Register Constraints
6 6.1
243
Evaluation Complexity
LINEARSCAN takes linear time to scan the intervals. For every interval it has to inspect the active, inactive and unhandled fixed sets in order to find overlaps. Since there cannot be more active intervals than registers, the length of the active set is bounded by the number of registers, which is a small constant. The length of the inactive set can come close to the total number of intervals, which would lead to a quadratic time complexity in the worst case. In practice, however, there are only very few inactive intervals (typically less than 2) at any point in time so the behavior is still linear. Finally, the number of unhandled fixed intervals is bounded by the number of available registers, because fixed intervals with the same register are joined into a single interval. Therefore, if n is the number of live intervals, the overall complexity of our algorithm is O(n2) in the worst case but linear in practice. During preprocessing we have to generate moves for φ-functions. This takes time proportional to the number of φ-functions, which is smaller than n. Live intervals are generated in sorted order so we do not need a separate pass to sort them. 6.2
Comparison with Related Work
The novelty of our approach lies in the fact that it is applicable to programs in SSA form and that it can deal with values that have to reside in specific registers. The adaptations for SSA form are done in a preprocessing step in which moves are inserted into the instruction stream in order to neutralize the φ-functions. After this step, SSA form does not affect the linear scan register allocation since φ-functions do not show up in the live intervals any more. In contrast to Poletto and Sarkar [11] our linear scan algorithm can deal with lifetime holes and fixed intervals, which makes it more complicated: In addition to the three sets unhandled, handled and active we need a fourth set, inactive, to hold intervals with a hole into which the start of the current interval falls. We also have to exclude registers that are occupied by overlapping fixed intervals from the register selection. Otherwise our algorithm is very close to the one described in [11]. Traub et al. [13] emit spill and reload instructions during register allocation eliminating a separate pass in which the instruction stream is rewritten. A spilled value can be reloaded into any free register later so that a value can reside in different registers during its life. While the ability to split long intervals is definitely an advantage, SSA form tends to produce shorter intervals from the beginning. For example, the live interval of the value v in Fig. 17a is [1,9[. In SSA form (Fig. 17b) the interval is split into 4 intervals ([1,2[, [4,7[, [9,10[, [12,12[), each of which can reside in a different register. Therefore the need for interval splitting seems not to be as urgent as without SSA form. Traub’s algorithm has to insert register moves at certain block boundaries because values can be in different locations at the beginning and the end of a control flow edge. In a similar way, we insert moves for the operands of φ-functions (instructions 7 and 10 in Fig. 17b) and eliminate unnecessary moves by coalescing values later.
244
Hanspeter Mossenbock and Michael Pfeiffer
2: . 4: v l
=
vo
=
6: ...
...
9: v2 = ...
I
11: v3 =$@I,t2) 12: . = v 3 Fig. 17. Length of live intervals a) without and b) with SSA form
6.3
Measurements
The first version of our compiler used a graph coloring register allocator, which we later replaced by a linear scan allocator. In order to compare their speed we compiled the first 1000 classes of the Java class library. Fig. 18 shows the time used for register allocation (in milliseconds) depending on the size of the compiled methods (in bytecodes). We can see that linear scan has a nearly linear time behavior and remaim efficient even for larger methods, whereas the time for graph coloring tends to increase disproportionally. For large programs linear scan is several times faster than graph coloring. 20.0 180 16.0 14.0 12.0 100 80 6.0 4.0 2.0 00
A
0
200
400
600
800
1000
Fig. 18. Run time of graph coloring vs. linear scan
linear scan
1200
Linear Scan Register Allocation in the Context of SSA Form and Register Constraints
7
245
Summary
We described how to adapt the linear scan register allocation technique for programs in SSA form. Due to SSA form the live intervals of most values become short and allow us to keep the same variable in different registers during its lifetime without splitting live intervals. We also showed how to deal with values that have to reside in specific registers as it is common in many CISC architectures.
Acknowledgements. We would like to thank Robert Griesemer, Srdjan Mitrovic and Kenneth Russell from Sun Microsystems for supporting our project as well as the anonymous referees for providing us with valuable comments on an early draft of this paper.
References 1.
Aho, A.V., Sethi, R., Ullman, J.D.: Compilers: Principles, Techniques, and Tools. Addison-Wesley (1986) 2. Appel, A.W.: Modern Compiler Implementation in Java. Cambridge University Press (1998) 3. Briggs, P., Cooper, K., Torczon, L: Improvements to Graph Coloring Register Allocation. ACM Transactions on Programming Languages and Systems 16, 3 (1994) 428-455 4. Chaitin, G.J., Auslander, M.A., Chandra, A.K., Cocke, J., Hopkins, M.E., Markstein, P.W.: Register Allocation via Coloring. Computer Languages 6 (1981) 47-57 5. Chow F. C., Hennessy J. L.: The Priority-Based Coloring Approach to Register Allocation. ACM Transactions on Programming Languages and Systems 12, 4 (1990) 501-536 6. Cytron, R., Ferrante, J., Rosen, B.K., Wegman, M.N.: Efficiently Computing Static Single Assignment Form and the Control Dependence Graph. ACM Transactions on Programming Languages and Systems 13, 4 (1991) 451 - 490 7. Griesemer, R., Mitrovic, S.: A Compiler for the Java HotSpot™ Virtual Machine.. In Böszörmenyi et al. (ed.): The School of Niklaus Wirth. dpunkt.verlag (2000) 8. Johansson, E., Sagonas, K.: Linear Scan Register Allocation in the HiPE Compiler. International Workshop on Functional and (Constraint) Logic Programming (WFLP 2001), Kiel, Germany, September 13-15, 2001 9. Mössenböck, H.: Adding Static Single Assignment Form and a Graph Coloring Register Allocator to the Java HotSpot Client Compiler. TR-15-2000, University of Linz, Institute of Practical Computer Science, 2000 10. Poletto, M., Engler, D.R., Kaashoek, M.F.: A System for Fast, Flexible, and High-Level Dynamic Code Generation. Proceedings of the ACM SIGPLAN Conf. on Programming Language Design and Implementation, Las Vegas (1997) 109-121
246
Hanspeter Mössenböck and Michael Pfeiffer
11. Poletto, M., Sarkar, V.: Linear Register Allocation. ACM Transactions on Programming Languages and Systems 21, 6 (1999) 895-913 12. Sedgewick, R.: Algorithms, 2nd edition. Addison Wesley (1988) 13. Traub, O., Holloway, G., Smith, M.D.: Quality and Speed in Linear-Scan Register Allocation. Proceedings of the ACM SIGPLAN Conf. on Programming Language Design and Implementation (1998) 142-151
Global Variable Promotion: Using Registers to Reduce Cache Power Dissipation Andrea G. M. Cilio1 and Henk Corporaal2 1
Delft University of Technology, Computer Engineering Dept. Mekelweg 4, 2628CD Delft, The Netherlands
[email protected] 2 IMEC, DESICS division Leuven, Belgium
[email protected] Abstract. Global variable promotion, i.e. allocating unaliased globals to registers, can significantly reduce the number of memory operations. This results in reduced cache activity and less power consumption. The purpose of this paper is to evaluate global variable promotion in the context of ILP scheduling and estimate its potential as a software technique for reducing cache power consumption. We measured the frequency and distribution of accesses to global variables and found that few registers are sufficient to replace the most frequently referenced variables and capture most of the benefits. In our tests, up to 22% of memory operations are removed. Four registers, for example, are sufficient to reduce the energy-delay product by 7 to 26%. Our results suggest that global variable promotion should be included as a standard optimization technique in power-conscious compilers.
1
Introduction
Certain code optimizations, like register allocation, offer increased potential for code improvement when applied to whole programs. Several research works, some of which resulting in a production compiler [15], have explored the potential of inter-module register allocation and global variable promotion. The latter technique allocates global variables in registers for a part of the lifetime crossing procedure and module boundaries (possibly for the entire lifetime). These works have always considered execution time the primary metric of evaluation. However, as we show in this paper, in the context of instruction scheduling for ILP processors performance is not so sensitive to inter-module register allocation; in this context, earlier results do not apply anymore.. With the increasing importance of low-power designs, due to the rapidly growing portable electronics market, we believe that metrics like energy and energy-delay product should be used to evaluate these and other software techniques. From the point of view of execution cycle count, reserving a register to a global variable throughout the program lifetime is advantageous when the target architecture offers enough registers with respect to the number of interfering live R. N. Horspool (Ed.): CC 2002, LNCS 2304, pp. 247–261, 2002. c Springer-Verlag Berlin Heidelberg 2002
248
Andrea G. M. Cilio and Henk Corporaal
ranges, which may be limited by, e.g., the lack of instruction-level parallelism. In these situations, a number of registers may be left underutilized. Modern multimedia, general-purpose and DSP processors, like Trimedia TM1000 [7], Intel’s IA-64 and Analog Devices’ ADSP-TS001M, offer large register files. Although this large number of registers is necessary to sustain high levels of ILP, the compiler ILP-enhancing techniques may not always succeed in utilizing them all effectively. By assigning underutilized registers to global scalar variables, the compiler can eliminate all the load and store operations that access those variables, thereby reducing the dynamic operation count and the cache-processor traffic. From the point of view of power consumption, this is advantageous, because a large fraction of the overall power consumption in modern processors is due to cache activity [12]. The purpose of this paper is to evaluate global variable promotion in the context of instruction-level parallel (ILP) scheduling and to estimate its potential as a software technique for reducing cache power consumption. Also, we investigate possible trade-offs points between execution time and energy consumption for different caches and CPU configurations with varying degrees of ILP. The rest of this paper is organized as follows. Section 2 analyses the potential of global variable promotion and inter-module register allocation and presents the algorithm used to promote global scalar variables. Using the power dissipation model presented in section 3, section 4 evaluates the effect of global variable promotion on performance and two energy-related metrics. Section 5 reviews related work. Finally, section 6 summarizes the results obtained.
2
Global Register Allocation
A number of code generation systems extend the program analyses and optimizations to the inter-module (or whole-program) scope. Among these optimizations, inter-module register allocation and global variable promotion have received some attention [18] [17] [2] [15]. In this section we first evaluate the potential of these two optimizations techniques on our compiler. After concluding that only global variable promotion seems promising, we present an algorithm for global variable promotion. 2.1
Potential of Inter-module Register Allocation and Global Variable Promotion
Inter-module Register Allocation (and its restricted inter-procedural variant) aims at reducing the execution overhead due to save and restore code around function calls. While this can be effective when compiling for languages with frequent function calls, like LISP [17], the potential measured in other works, even though using more sophisticated approaches, seems low for languages like C and Pascal; the speedup ranges from 1 to 3% [2] [15]. To verify that the potential of inter-module register allocation is scarce in our C compiler (based on gcc), we performed a number of tests with and without
Global Variable Promotion
249
Table 1. Effect of function inlining on a set of benchmarks % call operations % size % cycles benchmark original inline increase reduction compress 0.368 0.005 18.143 1.557 cjpeg 1.263 0.063 6.682 11.306 djpeg 0.209 0.018 3.089 2.119 mpeg2dec 1.395 0.132 24.472 30.802 average 0.809 0.055 13.097 11.446
Table 2. Potential speedup of inter-module allocation of local variables: upper bounds % reduction cycles mops benchmark original inline original inline compress 2.305 0.010 6.499 0.044 cjpeg 1.124 0.748 8.919 3.906 djpeg 0.334 0.198 2.844 0.721 mpeg2dec 15.731 1.809 36.441 4.571 average 4.873 0.691 13.676 2.311
function inlining (see table 1). Details about the target machine can be found in section 4.3, while the benchmarks are presented in section 4.2. Columns 4 and 5 of Table 1 show, respectively, the code size increase and the speedup of the inlined program with respect to the original program. Function inlining drastically reduces the number of function calls at the cost of a modest code size increase. The very low fraction of call operations after inlining (column 3) suggests that save and restore code does not constitute a large overhead. A good upper bound to the speedup that could be achieved by means of inter-module register allocation is obtained by totally disabling the generation of save and restore code around calls. The performance is correctly measured by our cycle-accurate simulator, which takes care of saving and restoring the used registers “on behalf” of the program. Columns 2 and 3 of table 2 show the speedup obtained when the original and the inlined versions of the programs are compiled without generating save/restore code, while the last two columns show the reduction in memory operations. From these data we can conclude that the potential of inter-module register allocation is negligible after function inlining has been applied. Also, notice that this upper bound is not always achievable: recursive functions, for example, still require some save and restore code. In addition to the low fraction of function calls, another reason contributes to these very low upper bounds: the caller- and callee-saved register conventions [2] are effectively used in our compiler [8] to minimize the unnecessary save and restore code for registers that are not live around a function call.
250
Andrea G. M. Cilio and Henk Corporaal
Table 3. Memory operations and accesses to global scalar variables as fractions of all operations and of memory operations executed, respectively % globals benchmark mops % unscheduled scheduled compress 31.4 33.0 25.9 cjpeg 24.8 26.5 16.0 djpeg 22.8 18.5 8.6 mpeg2dec 31.3 20.4 12.7 average 25.58 24.6 15.8
Promoting global scalar variables appears to be more promising than intermodule register allocation. Previous works reported speedups ranging from 7% [15] to 10–20%, for a set of small benchmarks [18] and found that global variable promotion is of greater benefit than inter-procedural register allocation. These works have also shown that scalar variable accesses represent a substantial fraction of the total number of memory operations that access global (static) data. Our measurements, however, do not fully confirm this fact, as shown in table 3. Columns 3 and 4 contain the total accesses to global scalar variables as a fraction of the total memory operations. These values have been measured in unscheduled (and only partially optimized) and scheduled code, respectively. The measured difference can be ascribed to function inlining (which is not applied to unscheduled code) and the additional optimizations performed during scheduling. The difference with previously reported results can be partially explained by the fact that, while we only count variables residing in memory, the baseline register allocator used by Wall [18] considers also constants and link-time constant addresses ‘globals’, and stores them in memory. These amount to a substantial portion of the overall memory references. In fact, Wall reports that the most important globals are few, frequently used numeric constants, and that keeping them in global registers captures much of the link-time allocation advantage. Since our compiler encodes all constant values (including link-time constant addresses) in immediate fields, it is not surprising that we find fewer globals. 2.2
Algorithm for Global Variable Promotion
The scarce potential shown by inter-module optimization, discussed in previous section, lead us to focus on variable promotion. The results reported by Santhanam [15], suggest that a simple algorithm for global variable promotion performs almost as well as the most sophisticated. For this reason, we chose blanket promotion, a simple algorithm which replaces a set of selected global variables with registers throughout the program. To obtain alias information on global-scope scalar variables, we added a postlinkage analysis pass. This pass determines which variables have their address taken in at least one of the modules and are thus not eligible for promotion. All
Global Variable Promotion
251
unaliased global variables are candidates for assignment to registers. The decision of which global variable to select, given a budget of registers for promoted variables, is taken based on the number of load and store operations that would be eliminated. The frequencies are obtained with profiling. Variable promotion is applied after all modules and library functions have been linked together, before instruction scheduling [3].
3
Cache Power Consumption
The power dissipation due to on-chip caches is a significant portion of the overall power dissipated by a modern microprocessor. For example, the on-chip D-cache of a low-power microprocessor, the StrongARM 110, consumes 16% of its total power [12]. The current trend towards larger on-chip L1 caches emphasizes the importance of reducing their power dissipation for two reasons: first, larger caches require larger capacitances to be driven; second, larger L1 caches have higher hit rate and therefore reduce the relative power spent in L2 caches or in off-chip memory communication. 3.1
Cache Power Model
To evaluate the reduction of cache power dissipation we used the analytical model for cache timing and power consumption found in CACTI 2.0 [14], which is based on the cache model proposed by Wilton and Jouppi [19]. The source of power dissipation considered in this model is the charging and discharging of capacitative loads caused by signal transitions. The energy dissipated for a voltage transition 0 → V or V → 0 is approximated with: E=
1 CV 2 2
(1)
where C is the capacitance driven. An analytical model of the cache power consumption includes the equivalent capacitance of the relevant cache components. The power consumption is estimated by combining (1) and the transition count at the inputs and outputs of each modeled component. The cache components fully modeled are: address decoder, wordline, bitline, sense amplifiers, data output driver. In addition, the address lines going off-chip and the data lines (both going off-chip and going to the CPU, are taken into account. Our model does not consider the power dissipated by comparators, data steering logic, and cache control logic. This model is quite accurate; Kamble and Ghose [10] have shown that their model, which is very similar to this one, if coupled with exact transition counts, predicts the power dissipation of conventional caches (i.e., caches whose organization does not use power-reducing techniques like sub-banking and block buffering) with an error within 2%. In our estimations we use accurate counts for cache accesses and address bit transitions to and from memory. The average width of a piece of data written to memory is estimated assuming equal distribution of
252
Andrea G. M. Cilio and Henk Corporaal
bytes, half-words and (32-bit) words, like in [12]. Also, we estimate that the transition counts of address and data bits are evenly distributed between accesses that hit and miss the cache. 3.2
Energy-Related Metrics
To evaluate the efficiency of global variable promotion we measure the energydelay (E-D) product. This metric was proposed by Gonzales and Horowitz [5], who argue it is superior to the commonly used power or energy metrics because it combines energy dissipation and performance. To compute the delay D we assumed a clock frequency compatible with the access times estimated by CACTI. The E-D product is given by: ED = E · D = P · D2 = P · (Ncycles · Tclock )2 .
(2)
Although the E-D metric presents important advantages, like the reduced dependence from technology, clock speed and implementations, the energy consumption is an important metric for battery-operated processors in portable devices, because it determines their battery duration [1]. In our experiments, the energy reduction closely follows the reduction of energy-delay. Nevertheless, we do show these results.
4
Experimental Results
We present the results of our simulations in this section. First, we briefly introduce our code generation infrastructure, our benchmarks and the target machines used for the simulations. The results are presented in three parts: the frequency distribution of global variables, the performance results and the energy efficiency of the data cache. 4.1
Code Generation Infrastructure
Figure 1 shows our code generation path. It generates code for a templated architecture especially suited for Application Specific Instruction-Set Processors, called Move. This architecture offers explicitly programmed instruction-level parallelism, in a fashion similar to that of VLIW architectures [4]. For the purpose of this paper, the details of the architecture used for the evaluation are unimportant. Its inherently low-power characteristics, however, make the contribution of caches to the overall chip power consumption even larger than in a conventional architecture. The code generation is coarsely split in two phases: (1) compilation to a generic-machine instruction set and (2) target-specific instruction scheduler, which integrates also register allocation [9]. Simulation of the generic, unscheduled code is used to generate profiling data. The intermediate representation used in the first phase of code generation is SUIF, the Stanford University Intermediate Format [6].
Global Variable Promotion
253
C, Fortran source front-end SUIF IR other modules MachSUIF IR
linker
machine-indep. optimizations
back-end MachSUIF IR
machine-dep. optimizations
scheduler executable
Fig. 1. The adapted code generation trajectory Table 4. Benchmarks used for evaluation benchmark compress djpeg cjpeg mpeg2decode
instr. 4855 16421 16526 12935
cycles 2.0M 19.7M 29.8M 30.3M
description Unix utility for file compression. JPEG image decompression. JPEG image compression. Standard MPEG-2 format decoder.
Instead of generating the traditional assembly textual output, the compiler generates and maintains a structured representation of the machine code in MachSUIF [16], a format derived from SUIF. MachSUIF maintains all sourcelevel information, as well as any other piece of information gathered during analysis passes. This format allows to perform sophisticated code analysis on whole programs and makes the related code transformation much easier to apply than on a binary format [3]. 4.2
Benchmark Characteristics
Four benchmarks have been used for our experimental evaluations. Their static code size and dynamic operation count (test set) are summarized in table 4. All benchmarks have been profiled with a training input data set and tested with a different data set. We selected multi-module programs of a sufficient level of complexity, such that the use of global (scalar) variables is almost unavoidable. Small benchmarks, on the other hand, are often coded without using global scalar variables. Compress, with its relatively small size, is an exception, in that it is a single-source, simple program with frequent accesses to global scalar variables.
254
Andrea G. M. Cilio and Henk Corporaal
Table 5. Machine configurations used for the evaluation quantity resource M1 M2 transport busses 3 8 long immediates 1 2 # integer regs. varies varies # FP regs. 16 48 # boolean regs. 2 4 cache size 16KB 32KB
4.3
quantity unit latency M1 M2 LSU 2 1 2 IALU 1 2 4 multiply 3 1 1 divide 8 1 1 FPU 3 1 1
Target Machines
We performed our evaluation on two Move target machines with different cost and capabilities. The two machine configurations were selected in order to evaluate how ILP affects the results of global variable promotion. Our Move architecture is kind of VLIW machine with streamlined reduced instruction set. The smaller machine, M1, is slightly more powerful than a simple single-issue RISC processor.1 The average IPC measured for our benchmarks ranges between 1.2 and 1.3. We selected this configuration in order to estimate the effect of global promotion on a single-issue machine. The larger machine, M2, is capable to perform about 4 operations per cycle, two of which can be data memory accesses. In this case, the average IPC measured for our benchmarks is 1.7–2.3. Table 5 summarizes the characteristics of the machine configurations. The busses are explicitly programmed to transport data between execution units and register files. The boolean registers allow to guard operations and predicate their execution. We assumed that the CPU is attached to a 2-way set-associative, write-through, on-chip data cache with LRU replacement policy. The cache line size is 32 bytes. Although the results shown in the following sections were obtained with 16KB and 32KB caches, other cache sizes have been tried. For all configurations the relative energy reduction is very similar. 4.4
Distribution of Global Variable Uses
The number of accesses to the memory segment dedicated to global data varies widely from benchmark to benchmark [13]. The relative frequency of memory operations that access global scalar variables poses a clear upper bound on the improvement of energy efficiency achievable via global variable promotion. Fortunately, the accesses to global scalar variables have a desirable characteristic. As shown in figure 2(a), only a few variables are sufficient to cover most memory operations due to accesses to global scalar variables. The values on the Y axis are 1
Due to limitations in the current implementation, the integrated instruction scheduler/register allocator cannot generate code for a machine configuration with only one integer ALU.
Global Variable Promotion
Memory Operations (fraction of total) 0.4
Memory Operations (fraction of total) 0.4
djpeg cjpeg mpeg2dec compress
0.35 0.3
djpeg cjpeg mpeg2dec compress
0.35 0.3
0.25
0.25
0.2
0.2
0.15
0.15
0.1
0.1
0.05
0.05
0
0 0
(a)
255
5
10 Global Variables Promoted
15
20
0
(b)
5
10
15
20
Global Variables Promoted
Fig. 2. Dynamic memory operation count covered by global scalar variables (a) on scheduled and optimized code, (b) on unscheduled code
the number of memory operations (as a fraction of the total memory operation count) due to the N most used global scalar variables, where N is reported on the X axis. This indicates that it is sufficient to dedicate only few registers to global variables to capture most of the benefit of global variable promotion. The results shown in figure 2(a) refer to scheduled and highly optimized code on M1, for which most of intra-procedural unaliased accesses to global variables have been optimized away. Code optimizations considerably reduce the relative frequency of accesses to global variables, as confirmed by figure 2(b), which depicts the same frequency distribution obtained from unscheduled code. Part of this reduction is accounted for by function inlining, which opens new opportunities for intra-procedural optimizations. 4.5
Performance
We compiled 9 different versions of each benchmark, with a budget dedicated to global variables ranging from 0 to 8 registers. For a given register budget n, the n most frequent global variables were promoted, resulting in the same number of registers not being available for general register allocation. The first series of tests measures the effect of global variable promotion on performance. Figure 3 shows the cycle count of the four benchmarks for different sizes of the integer register file. The modest speedup can be explained by the fact that load operations associated with global variables have a constant address and do not have flow dependencies with preceding operations, therefore can be scheduled with considerable freedom. Thanks to this freedom, our instruction scheduler is capable of hiding the latency of most load operations associated with global variables. The results in figure 3 confirm that the effect of scheduling freedom prevails, thus making the performance improvement modest to negligible depending on the benchmark. Figure 4 shows the dynamic count of load and store operations for the same series of tests. The reduction in memory operations executed is in good accordance with the usage distributions in figure 2, except when the most register-
256
Andrea G. M. Cilio and Henk Corporaal
Total Cycles (relative to baseline) 1.1
Total Cycles (relative to baseline) 1.1
024 regs 032 regs 064 regs 128 regs
1.08 1.06
024 regs 032 regs 064 regs 128 regs
1.08 1.06
1.04 1.02
1.04
1
1.02
0.98 1
0.96 0.94
0.98 0
1
2 3 4 5 6 compress Global Variables Promoted
7
8
0
1
Total Cycles (relative to baseline) 1.1
1.06
7
8
7
8
Total Cycles (relative to baseline) 1.1
024 regs 032 regs 064 regs 128 regs
1.08
2 3 4 5 6 djpeg Global Variables Promoted
024 regs 032 regs 064 regs 128 regs
1.08 1.06
1.04
1.04
1.02 1.02 1 1
0.98 0.96
0.98
0.94
0.96
0.92
0.94 0
1
2 3 4 5 6 7 mpeg2decode Global Variables Promoted
8
0
1
2 3 4 5 6 cjpeg Global Variables Promoted
Fig. 3. Performance results: dynamic cycle counts on ‘M1’
hungry benchmarks are run on machine configurations with a small integer register file. In such cases, the reduced number of registers available to general register allocation quickly offsets the gains of variable promotion. This is due to the introduction of false dependencies, which pose a tighter constraint on scheduling freedom. A further increase in register pressure results in a large number of spill operations. We also measured the miss rate of the data cache and found that it increases as more global variables are promoted. Obviously, this is only a relative increase, due to the fact that the number of memory accesses decreases more than the number of cache misses. This result confirms that global variables show high temporal locality [13]. We can therefore conclude that global promotion reduces cache activity but does not significantly affect the CPU-memory traffic. 4.6
Energy and Energy-Delay Product
A reduction of energy and energy-delay product, consistent with the reduction of memory operations, has been measured for configurations of M1 and M2 with varying number of registers. Figure 5 shows the results for M1 with 64 registers relative to the original program without variable promotion. Very similar reductions are found for the M2 configuration with 64 registers, as can be seen from figure 6. In this case a 32KB cache was measured.
Global Variable Promotion
Memory Operations (relative to baseline) 1.1
Memory Operations (relative to baseline) 1.1
024 regs 032 regs 064 regs 128 regs
1.05
257
024 regs 032 regs 064 regs 128 regs
1.05
1 0.95
1
0.9
0.95
0.85 0.9 0.8 0.75
0.85 0
1
2 3 4 5 6 compress Global Variables Promoted
7
8
0
1
Memory Operations (relative to baseline) 1.1
7
8
7
8
Memory Operations (relative to baseline) 1.1
024 regs 032 regs 064 regs 128 regs
1.05
2 3 4 5 6 djpeg Global Variables Promoted
024 regs 032 regs 064 regs 128 regs
1.05
1
1
0.95 0.95 0.9 0.9
0.85
0.85
0.8 0.75
0.8 0
1
2 3 4 5 6 7 mpeg2decode Global Variables Promoted
8
0
1
2 3 4 5 6 cjpeg Global Variables Promoted
Fig. 4. Performance results: dynamic memory operation counts on ‘M1’
While the level of ILP seems not to have a significant impact on the effect of global variable promotion, the number of available registers is critical. Figure 7 shows the energy-delay product on M1 and M2 when only 32 registers are available. Only compress shows consistent improvement, owing to its low register pressure; in all other benchmarks, the register pressure results in more spill code and cache activity when promoting too many globals. The reduction in energy consumption is paired with reduced execution times, as can be seen by comparing figure 3 with figures 5, 6, and 7, therefore we cannot speak of a clear trade-off between performance and energy consumption for this software technique. This is easy to explain, since the primary source of performance degradation caused by global variable promotion is register pressure, which often results in register spilling and therefore additional memory operation and increased cache activity.
5
Related Work
In this section we review previous work on architectural/software techniques for reducing data cache power consumption. Work on whole-program register allocation has been briefly discussed in section 2. It has recently been demonstrated that memory traffic due to references to the global section of a program (which includes scalar global variables) shows
258
Andrea G. M. Cilio and Henk Corporaal
Energy Consumption (relative to baseline) 1
Energy-Delay (relative to baseline) 1
djpeg cjpeg compress mpeg2dec
0.95
djpeg cjpeg compress mpeg2dec
0.95 0.9
0.9 0.85 0.85 0.8 0.8
0.75
0.75
0.7 0
1
2
3 4 5 6 Global Variables Promoted
7
8
0
1
2
3 4 5 6 Global Variables Promoted
7
8
Fig. 5. Relative energy consumption (right) and energy-delay product (left) for a configuration of ‘M1’ with 64 integer registers
Energy Consumption (relative to baseline) 1
Energy-Delay (relative to baseline) 1
djpeg cjpeg compress mpeg2dec
0.95
djpeg cjpeg compress mpeg2dec
0.95 0.9
0.9 0.85 0.85 0.8 0.8
0.75
0.75
0.7 0
1
2
3 4 5 6 Global Variables Promoted
7
8
0
1
2
3 4 5 6 Global Variables Promoted
7
8
Fig. 6. Relative energy consumption (right) and energy-delay product (left) for a configuration of ‘M2’ with 64 integer registers very high temporal locality, with an average life span of cache lines up to almost one order of magnitude higher than that of accesses to heap region [13]. For this reason, most traffic due to accesses to global variables can be captured by a small dedicated cache. Since stack accesses show even better cacheability, the authors subdivide the data cache into three region caches which cover global data, stack and heap. This three-component on-chip cache system is much more power-efficient than the conventional single data cache: 37% to 56% less power is dissipated, depending on the cache configuration. Another recent work on architectural-level low-power cache design is presented by Kin and others [12], who propose to insert an unusually small cache before what normally is the L1 on-chip cache. This small cache, called filter cache, reduces the access cost by roughly a factor 6 at the cost of increased cache miss rate and increased miss latency. This allows to trade-off power efficiency with performance. The authors show that a clear optimal point exists between no filter cache at all and a filter cache of the same size of the conventional L1 cache. With an optimal filter cache size (512 bytes) the energy-delay product is reduced by 50% at the expense of a 21% increase in cycle count.
Global Variable Promotion
Energy-Delay (relative to baseline) 1
Energy-Delay (relative to baseline) 1.15
djpeg cjpeg compress mpeg2dec
0.95
259
djpeg cjpeg compress mpeg2dec
1.1 1.05
0.9
1 0.95
0.85
0.9
0.8
0.85 0.8
0.75
0.75
0.7
0.7 0
1
2
3 4 5 6 Global Variables Promoted
7
8
0
1
2
3 4 5 6 Global Variables Promoted
7
8
Fig. 7. Relative energy-delay product for a configuration of ‘M1’ (right) and ‘M2’ (left) with 32 integer registers The use of global variable promotion to reduce power consumption proposed in this paper exploits the same principles used in the Filter Cache [12] and the Region-Based Cache [13]. While the former exploits the locality principle to decrease power consumption by introducing a new level in the memory hierarchy, our approach achieves a similar result by using the register file. The registers allocated to frequently accessed global scalar variables can be also compared to the Region-Based cache partition dedicated to global data references. In this case, the use is further limited to a selected subset of scalar global variables. Many other architectural techniques for improving the energy efficiency of caches have been proposed. Kamble and Ghose, for example, evaluate the effectiveness of two such techniques: block buffering and sub-banking. The interested reader is referred to their paper [11] and to the section on previous work in [13] for further references about this important research area.
6
Conclusions
Power and energy consumption have become a critical issue in high-performance and portable/embedded processors, respectively. As a consequence, new microarchitectural and code generation techniques for power reduction are researched with increasing interest. At the same time, traditional software techniques, like loop unrolling take on a new light when energy-related metrics are considered [1]. Global variable promotion is in our opinion one of those software techniques that deserves new attention in the context of power reduction. In this paper we evaluated the effect of global variable promotion on performance and cache energy consumption, and found that significant savings, up to 26%, are achieved by promoting a few (4–8) critical global variables. In summary, the results suggest that on ILP architectures the effect of global variable promotion on performance is rather limited. However, this techniques can significantly reduce data cache power consumption, and should be included as a standard optimization technique in power-conscious compilers.
260
Andrea G. M. Cilio and Henk Corporaal
References 1. David Brooks, Vivek Tiwari, and Margaret Martonosi. Wattch: A framework for architectural-level power analysis and optimizations. In Proceedings of the 27th Annual International Symposium on Computer Architecture, pages 83–94, Vancouver, British Columbia, June 12–14, 2000. 252, 259 2. Fred C. Chow. Minimizing register usage penalty at procedure calls. In SIGPLAN ’88 Conference on Programming Language Design and Implementation, pages 85– 94, 1988. 248, 249 3. Andrea G. M. Cilio and Henk Corporaal. A linker for effective whole-program optimizations. In Proceedings of HPCN, Amsterdam, The Netherlands, April 1999. 251, 253 4. Henk Corporaal. Microprocessor Architectures; from VLIW to TTA. John Wiley, 1997. ISBN 0-471-97157-X. 252 5. R. Gonzalez and M. Horowitz. Energy dissipation in general purpose microprocessors. IEEE Journal of Solid-State Circuits, 31(9):1258–66, September 1996. 252 6. Stanford Compiler Group. The SUIF Library. Stanford University, 1994. 252 7. Jan Hoogerbrugge. Instruction scheduling for trimedia. Journal of InstructionLevel Parallelism, 1(1–2), 1999. 248 8. J. Janssen. Compilation Strategies for Transport Triggered Architectures. PhD thesis, Delft University of Technology, 2001. 249 9. Johan Janssen and Henk Corporaal. Registers on demand: an integrated region scheduler and register allocator. In Conference on Compiler Construction, April 1998. 252 10. M. B. Kamble and K. Ghose. Analytical energy dissipation models for low-power caches. In Proceedings of the 1996 international symposium on Low power electronics and design, Monterey, CA USA, August 12–14, 1997. ACM. 251 11. M. B. Kamble and K. Ghose. Energy-efficiency of vlsi caches: a comparative study. In Proceedings Tenth International Conference on VLSI Design, pages 261–7. IEEE, January 1997. 259 12. Johnson Kin, Munish Gupta, and William H. Mangione-Smith. Filtering memory references to increase energy efficiency. IEEE Transactions on Computers, 49(1), January 2000. 248, 251, 252, 258, 259 13. Hsien-Hsien S. Lee and Gary S. Tyson. Region-based caching: An efficient memory architecture for embedded processors. In CASES, San Jose, CA, November 2000. 254, 256, 258, 259 14. G. Reinman and N. P. Jouppi. An integrated cache timing and power model. Technical report, COMPAQ Western Research Lab, Palo Alto, California, 1999. 251 15. Vatsa Santhanam and Daryl Odnert. Register allocation across procedure and module boundaries. In Proceedings of the Conference on Programming Language Design and Implementation, pages 28–39, 1990. 247, 248, 250 16. Michael D. Smith. Extending SUIF for Machine-dependent Optimizations. In Proceedings of the First SUIF Workshop, January 1996. 253 17. Peter A. Steenkiste and John L. Hennessy. A simple interprocedural register allocation algorithm and its effectiveness for lisp. TOPLAS, 11(1), 1989. 248 18. David W. Wall. Register windows vs. register allocation. Technical Report 7, Western Research Laboratory, Digital Equipment Corporation, December 1987. 248, 250
Global Variable Promotion
261
19. S. J. E. Wilton and N. P. Jouppi. An enhanced access and cycle time model. Technical Report 5, Digital Western Research laboratory, Palo Alto, California, July 1994. 251
Optimizing Static Power Dissipation by Functional Units in Superscalar Processors Siddharth Rele1 , Santosh Pande2 , Soner Onder3 , and Rajiv Gupta4 1
Dept of ECECS, University of Cincinnati, Cincinnati, OH-45219 2 College of Computing, Georgia Tech, Atlanta, GA-30318 3 Dept. of Computer Science, Michigan Tech. Univ., Houghton, MI 49931 4 Dept. of Computer Science, The Univ.of Arizona, Tucson, Arizona 85721
Abstract. We present a novel approach which combines compiler, instruction set, and microarchitecture support to turn off functional units that are idle for long periods of time for reducing static power dissipation by idle functional units using power gating [2,9]. The compiler identifies program regions in which functional units are expected to be idle and communicates this information to the hardware by issuing directives for turning units off at entry points of idle regions and directives for turning them back on at exits from such regions. The microarchitecture is designed to treat the compiler directives as hints ignoring a pair of off and on directives if they are too close together. The results of experiments show that some of the functional units can be kept off for over 90% of the time at the cost of minimal performance degradation of under 1%.
1
Introduction
To cater to the demands for high performance by a variety of applications, faster and more powerful processors are being produced. With increased performance there is also an increase in the power dissipated by the processors. High performance superscalar processors achieve their performance by exploiting instruction level parallelism (ILP). ILP is detected dynamically and instructions are executed in parallel on multiple functional units. Therefore one source of power dissipation is due to the functional units. It is well known that ILP is often distributed nonuniformly throughout a program. As a result many of the functional units are idle for prolonged periods of time during program execution and therefore the power dissipation by them during these periods is wasted. The goal of this work is to minimize power dissipated by functional units by exploiting long periods of time over which some functional units are idle. The power consumed by functional units falls into two categories: dynamic and static. With current technology the dynamic power is the dominating component of overall power consumption and by using clock gating techniques the dynamic power dissipated by functional units during idle periods can be reduced [4,12,13]. However, it is projected that in a few generations the static power dissipation will
Supported by DARPA award no. F29601-00-1-0183.
R. N. Horspool (Ed.): CC 2002, LNCS 2304, pp. 261–275, 2002. c Springer-Verlag Berlin Heidelberg 2002
262
Siddharth Rele et al.
equal dynamic power dissipation [11]. Specifically for different kinds of adders and multiplers, the increase in static power with changing technology is shown in Table 1 [5]. Therefore it is important to also minimize the static power consumption when the functional units are idle. Table 1. Static power dissipation by functional units Functional unit type Adders Ripple Carry Carry Lookahead Manchester Carry Multipliers Serial Serial/Parallel Parallel
Technology (µm) 0.354 0.18 0.13 0.10 Static power dissipation (mW) 0.07 0.08 0.12 0.14 0.09 0.11 0.19 0.20 0.10 0.16 0.23 0.25 Static power dissipation (mW) 0.29 0.32 0.43 0.51 0.35 0.41 0.48 0.55 0.37 0.46 0.50 0.60
0.07 0.15 0.19 0.28 0.50 0.58 0.62
There are two known techniques that are suitable for reducing static power dissipation by functional units during long periods of idleness. The first technique is power gating [10,9] which turns off devices by cutting off their supply voltage. The second technique uses the dual threshold voltage technology – by raising the threshold voltage during idle periods of time the static power dissipation is reduced [14]. In both of the above approaches there is a turning on latency involved, that is, when the unit is turned back on (either by providing the supply voltage or lowering the threshold voltage) it cannot be used immediately because some time is needed before the circuitry returns to its normal operating condition. While the latency for power gating is typically few (5-10) cycles [2], the latency for dual threshold voltage technology is much higher. In this work we assume that power gating is being employed to turn off functional units and we assume a latency of ten cycles for turning a functional unit on in all our experiments. The shutting down of functional units is most effectively accomplished by employing a combination of compiler and hardware techniques. To understand the reasons for this claim lets examine the problems that we must address in designing an algorithm for turning functional units off and then back on, and then evaluate the suitability of the means for solving the problem, that is, whether to use compiler support or hardware support in addressing the problem. We also describe the approach that we take in addressing each of the problems. Identifying idle regions. In order to turn off a functional unit we first must identify regions of code in the program over which the functional unit is expected to be idle. The use of hardware for predicting or detecting idle regions has the following problems. First the additional hardware for predicting idle regions will also consume additional power throughout the execution as it must remain active
Optimizing Static Power Dissipation by Functional Units
263
all along. Second we will not be able to exploit the idle regions during the warm up period of the prediction mechanism – only after enough history has been acquired by the prediction hardware will the predictions be effective. Our solution to the above problems is to rely on the compiler to identify program regions with low ILP and thus low functional unit demands. The compiler can examine all of the code off-line and therefore identify suitable regions for turning the functional units off. Furthermore it can also identify the type of functional units and determine the number of functional units that should be turned off without degrading performance. This information is then communicated to the hardware by generating special off and on directives. Tolerating the latency of turning a functional unit off. The functional unit must be turned off sufficiently prior to entering the program region in which it can be kept idle. This is because there is a latency for turning the unit off and we must account for this latency to maximize the power savings. The latency arises because time is needed to drain the functional unit by allowing it to execute the instructions already assigned to it. Let us assume that we have two functional units of a given type and we would like to turn one of them off. When the off directive is encountered, the functional units may already have instructions assigned to them. One of the unit must be selected and drained before it is turned off. This problem is also not suitable for handling by hardware because even if we were to overcome the problems described earlier and develop a mechanism for efficiently detecting idle regions in hardware, now we would have to predict them even earlier. Therefore our solution is to allow the compiler to place the off directive sufficiently in advance of reaching the idle region whenever possible. Tolerating the latency of turning a function unit on. The functional unit must also be turned on prior to exiting the idle region. This is because there is a several cycle latency before which the functional unit comes on-line and is ready to execute operations [2]. By tolerating this latency we can minimize the performance degradation while executing instructions from the region following the idle region. Again our solution to this problem is to place the on directive sufficiently in advance of exiting the idle region whenever possible. Dealing with variable length idle regions. Sometimes the duration of an idle region may vary from being very small in one execution of the region to very long in the next execution of the same region. For example, the idle region may contain a while loop or conditionals which may lead to this variation. Introduction of an off directive in such a situation can be based upon a conservative policy or an aggressive policy. A compiler based upon a conservative policy will introduce the off and on directives only if it is certain that the duration of the idle region is long. The problem with this approach is that the reductions in power dissipation that could be obtained by turning a unit off are sacrificed. We propose to use an aggressive policy in which the compiler introduces the off and on directives to maximize savings. If the duration of the idle region is
264
Siddharth Rele et al.
long, power savings result. On the other hand if the duration is very small, the on directive is issued on the heals of issuing an off directive. If the latter situation arises frequently, while little or no savings in power result, some amount of dynamic power is dissipated during switching of the functional unit state. Moreover the performance is hurt as the functional unit goes off-line for several cycles each time such a spurious pair of off and on directives are encountered. We address this issue by providing adequate microarchitecture support for nullifying spurious off and on pairs. The microarchitecture is designed to treat the compiler directives as hints ignoring a pair of off and on directives if they are too close together. In this way the state of the unit is not actually switched, the unit stays on-line, and dynamic power for switching the unit off and on as well as the degradation in performance are minimized. We have incorporated the power-aware instructions into the MIPS-I instruction set and simulated a superscalar architecture which implements these instructions using our FAST simulation system [8]. The compiler algorithms have been incorporated into the lcc compiler. The results of experiments show that some of the functional units can be kept off for over 90% of the time resulting in a corresponding reduction in static power dissipation by these units. Moreover the power reductions are achieved at the cost of very minimal performance degradation – well under 1% in all cases. The remainder of the paper is organized as follows. In section 2 we discuss instruction set extensions and microarchitecture modifications required to implement the new instructions. In section 3 we discuss in detail the compiler algorithms for introducing on and off instructions. In section 4 we describe our implementation and in section 5 we present results of experiments. Conclusions are given in section 6.
2
Architectural Support
Power aware instruction set. As mentioned earlier, we support instructions that will allow us to turn functional units on or off. Such instructions must also indicate the type of functional unit that is to be turned on or off. The solution we developed adds an on or an off directive as a suffix to existing instructions. The type of functional unit that is to be turned on or off is the same type as that is used to execute the instruction to which the directive is added. In case multiple functional units of a particular type are present, the decision as to which specific unit will be turned off is left up to the hardware. In some architectures certain operations can be executed by functional units of more than one type (e.g., integer and floating point). However, we assume that in such cases the off and on directives are attached to instructions that must execute on a functional unit of specific kind. We have incorporated the on and off directives to the MIPS-I Instruction Set Architecture (ISA) which supports MIPS 32 bit processor cores. This ISA was selected for its simplicity and the availability of encoding space to allow us to encode on and off into existing instructions. A subset of instructions we
Optimizing Static Power Dissipation by Functional Units
265
add.on add.off mul.on mul.off add.s.on add.s.off mul.s.on mul.s.off mov.s.on
switch ON one integer adder switch OFF one integer adder switch ON one integer multiplier unit switch OFF one integer multiplier unit switch ON one float adder switch OFF one float adder switch ON one float multiplier unit switch OFF one float multiplier unit move values between float regs and switch ON float unit mov.s.off move values between float regs and switch OFFfloat unit
Fig. 1. A subset of energy-aware instructions modified is shown in Fig. 1. These instructions can also be issued without any operands in which case they do not perform any operation except for switching a unit of the appropriate type on or off. These are needed when on or off directives cannot be added to an existing instruction because the code does not already contain an instruction of the appropriate type around the point at which the compiler chooses to place the directive. On and off semantics for an out-of-order superscalar processor. The on directive is acted upon immediately following its detection, that is, when the instruction with the on suffix has been decoded, a functional unit of the appropriate type is turned on. It takes a few cycles for the circuitry to reach normal operational state after which the unit can perform useful work. The turning off of a functional unit cannot be done immediately following the decode. This is because if the unit that is turned off was the last on unit of its type, then no functional unit will be available for executing the instruction carrying the suffix and the processor will deadlock. Therefore in this case, following the decode, an on unit is selected and marked as pending-off. When the instruction that marks the unit retires, the unit is actually turned off and its status is changed from pending-off to off. This approach works because it guarantees that all instructions requiring the unit would have executed before the unit is turned off as all instructions are retired in-order even though they may execute on the functional unit out-of-order in the superscalar processor. At the same time, introduction of an off directive does not constrain the out-of-order execution capability of the processor. The states of the functional units are maintained as part of the processor state. A status table is maintained that indicates for each functional unit whether it is currently turned on, currently turned off, or if it is in the pending-off state. No new instructions are assigned to a functional unit by the issue mechanism if the unit is in off or pending-off state. Nullifying spurious off-on pairs. While savings in static energy consumption result when a functional unit is shutdown, a certain amount of performance
266
Siddharth Rele et al.
loss may be incurred when a unit is turned off as well as a certain amount of dynamic power is expended in bringing the circuit to its normal operating state. We rely upon the compiler to identify suitable idle regions during which turning off of a functional unit is not expected to hurt performance and the dynamic power expended in turning the unit on is far smaller than the static power saved by turning it off. For this strategy to work well, it is important that the idle regions be long in duration. However, it is possible that the code representing the idle region varies greatly in duration from its one execution to another. For example, the idle region may be formed by a while loop. If very little time is needed to execute the idle region then the unit will be turned off and then immediately turned on. In this situation the savings in static power will be minimal. However, loss of performance will still be incurred while executing the code immediately following the idle region and dynamic power will still be expended in turning the unit on. Our implementation of on and off is so designed that we are able to dynamically nullify spuriuous off and on pairs and thus avoid the dynamic power that would otherwise be dissipated during the transitions. When an instruction with off directive is encountered, a unit is selected and marked as pending-off. If an instruction with the on directive is encountered while the status of the unit is still pending-off, the unit state is changed to on from pending-off. When the instruction associated with the off directive retires, it will examine the status of the functional unit that it marked as pending-off. If the status is still pending-off, the unit is turned off; otherwise it is left on. Thus, the overall impact of the above approach is that if the on directive is encountered while the functional unit is in pending-off state, the functional unit is not actually turned off. Thus the off-on pair does not turn the unit off and then back on. 1 : .... 2 : mul.off – turn unit off 3 : if (x > 0) { 4: wait = 0; 5: while(1) { 6: wait = wait++; 7: if (wait == 1000) break; 8: } 9 : mul.on – turn unit on 10 : for (i = 0 ; i < 100; i++) 11 : sum += a[i] * 10; 12 : ....
Fig. 2. Nullification of OFF and ON pair For the example in Fig. 2, the code from line 3 to 8 takes very short time to execute when x ≤ 0; otherwise it takes a long time to execute. During the execution of this code we would like to turn the multiplier off since it is not required. If x > 0 we get power savings by turning the unit off. However, if
Optimizing Static Power Dissipation by Functional Units
267
x ≤ 0, the off and on directives are encountered in rapid succession and the unit is not turned off and then immediately turned back on. Before the instruction with the off directive retires, we would have already decoded the instruction with the on directive and changed the status of the unit from pending-off to on. Therefore when the instruction with off directive retires, it will find the functional unit status as on and therefore it will not turn it off. As a result the spurious off-on pair will be nullified.
3
Compiler Support
Our approach. Our compiler is designed to introduce off and on suffixed instructions in such a way that the following two goals are met. First we need to remove idleness by turning functional units off without causing an increase in program execution time (i.e., we want to reduce static power dissipation without causing performance degradation). Second the functional units that are turned off should be off for prolonged periods of time so that the dynamic power dissipated during on-off and off-on transitions is small in comparison to static power saved by keep the units off. Both the above goals are met by careful placement of on and off suffixed instructions. In order to achieve the first goal of minimizing performance degradation we take the following approach. We classify the basic blocks in a program into two categories: hot blocks whose execution frequencies are greater than a certain threshold value and cold blocks which are all the remaining blocks in the program. We also analyze the functional unit usage in each block to identify its requirements and consequently identify the units that are expected to be idle in that block. We place the off and on directives in cold blocks bordering the hot blocks in which the unit is expected to be idle. This situation is illustrated by the example in Fig. 3a. In contrast the example in Fig. 3b illustrates a situation in which we forego the removal of idleness since the block neighboring the hot block in which unit is idle is another hot block where the unit is not idle. This is because the potential placement points for off and on directives are also hot and therefore such instructions will be executed with high frequency. Thus, our approach removes idleness only if such removal does not adversely effect performance. In order to achieve the second goal of maximizing power savings mentioned above we do not place instructions carrying off and on directives at boundaries of a region formed by a single basic block. Instead we identify larger subgraphs in the control flow graph that represent control constructs (e.g., loops) which we refer to as power blocks. Then we classify the power blocks as hot or cold. In addition, from the requirements of individual blocks in a power block, we identify which functional units are idle throughout the execution of the power block. When power-aware code is generated, the off and on directives are placed at boundaries of power blocks using the principles described earlier and illustrated in Fig. 3.
268
Siddharth Rele et al. Cold OFF Hot
Hot
NOT IDLE
Hot
IDLE
IDLE ON
Cold (a) Reving idleness without performance degradation.
(b) Allowing idleness to avoid performance degradation.
Fig. 3. Idleness removal strategy We have given an overview of our approach. Now we describe the three main steps of our algorithm in more detail. The first step involves construction of an power-aware flow graph. The second step identifies the power blocks. The third and final step introduces the off and on suffixed instructions. The power-aware flow graph (PAFG). Our compiler begins by building the PAFG which is a control flow graph whose basic blocks are annotated with two types of information: the resource requirements; and the execution counts. The requirements of each block is calculated by first identifying the number of operations requiring each functional unit type in the block. This information by itself is enough for those functional unit types where only one functional unit of that type is present. If an operation requiring the functional unit of a certain type is present, the unit of that type is required. However, the above method is inadequate if there are multiple functional units of a given type. We must access the level of instruction level parallelism present in the operations that use the functional unit type to compute the requirements. The dependences among statements are examined to identify the parallelism and accordingly the requirements are computed. In particular, if two instructions that can execute in parallel require the same type of functional unit, then two such units are required. In other words the requirements of a basic block are computed such that they represent the number and type of units required to exploit the ILP present in the block. Another issue that must be considered during computation of requirements is that many instructions other than the integer add instruction may use the integer adder. For example, base + offset computation to compute the address of an array element requires an integer adder. The profile information that annotates the basic blocks is derived from prior executions of the program. This information is used for identifying hot blocks. If the execution count for a particular block is more than a threshold, it is considered to be hot. The threshold value is set according to the formula given below. In this formula N is a tunable parameter that can be changed to generate higher or lower number of hot blocks and thus control how aggressively idleness is removed.
Optimizing Static Power Dissipation by Functional Units
Threshold =
269
Execution Count of Most Frequently Executed Block . Some constant value N
An example code segment and its power-aware flow graph are shown in Fig. 4a and 4b. The requirements are annotated as a vector of values enclosed in angular brackets (the first value corresponds to integer adders and second for integer multipliers) while the profiling information is annotated as the execution count enclosed within square brackets. We set the threshold value as M axV alue/10 for identifying hot blocks. Identifying power blocks. In order to identify longer periods of time over which a functional unit can be turned off, we identify subgraphs representing larger constructs such as loops, if-statements, and switch statements. These subgraphs are referred to as power blocks. A hierarchical graph at the power block level is created in which each power block indicates the start and the end nodes of the subgraph forming the power block. In addition, a power block holds the summary of all the information regarding the basic blocks that form the power block. The requirements of a power block are computed from the requirements of the hot blocks in the block. The reason for this will be clear when we discuss how off and on directives are generated. There is only one entry point into a power block, that is, the start node of the power block dominates all the blocks inside the power block and hence the control has to flow through that block. Therefore if the start node is hot, the whole power block is marked as hot even though all the basic blocks belonging to it may not be hot. The higher level tree constructed from power blocks for our example is shown in Fig. 4c. Each leaf in this tree is a basic block. Internal nodes corresponding to higher level control constructs are the power blocks. Inserting power-aware instructions. Once all the information regarding the requirements of each basic as well as power block is recorded in the respective blocks, we traverse the PAFG for code generation. Our basic approach for introducing the off and on instructions is as follows: – For each user function we start by turning all units, except a minimal configuration of units, off. The minimal configuration is required so that execution can proceed and the processor does not deadlock. Typically this configuration will include an integer adder. – For each call to a library function we assume that all units are on during the execution of the library function. This is because we do not analyze code for library functions and therefore in order to guarantee that no performance degradation occurs, we must keep all units on. Instructions to turn on units that are off are therefore introduced immediately prior to the call and upon return these units can be again turned off. The impact of this restriction can be reduced by performing our optimizations at link time.
270
Siddharth Rele et al.
1: void main() { 2: for (i = 0 ; i < 100 ; i++) 3: if(sum < 1000) 4: sum = sum + arr[i]; 5: else { 6: sum = sum / 1000; 7: count++; 8: } 9: print(count,sum); 10 : } (a) Sample code segment.
Start
add.off mul.off
[1]
Start
mul.off
[1]
i = 0;
i = 0;
[100]
False
sum < 1000
add.on
True [96]
[4]
sum = sum / 1000; count++; i++
sum = sum + arr[i] i++
sum < 1000
False
mul.on
True sum = sum + arr[i] i++
[100]
i < 100
True
sum = sum / 1000; count++; i++
mul.off
False
[1]
print(count,sum) True
[1]
i < 100 False
End
(b) Power-aware flow graph.
mul.on mul.on
Func
i=0
Loop
print(count,sum)
Func()
End
End < 2, 0>
sum < 1000
(d) Final code.
i < 100
IF
sum [+]
Hot Blocks
sum [/]
Hot Power Blocks
(c) Hierarchical tree with power blocks.
Fig. 4. Introducing directives
Optimizing Static Power Dissipation by Functional Units
271
– If a particular user function is called in a hot block such that the number of calls to the function exceed the threshold, then the current framework bypasses the analysis of that function, on the grounds that any switching inside this function would be too frequent and hence not beneficial (it may in fact jeopardize the execution speed). – We compare each block with all its successors to check if there is a difference in the power requirements of the blocks. If there is a difference, then we try to generate off and on instructions at the boundaries after checking whether the blocks involved are hot or cold according to the strategy outlined earlier in this section. When a hot power block is adjacent to cold blocks, typically off instructions are generated prior to entering the power block. From the requirements of the power block we identify the units to be turned on or off. Recall that the requirements of the power block are computed from hot blocks in it. Therefore within the hot power block there may be cold blocks which require a unit that is currently off. Therefore, upon entry to such a cold block such a unit is turned on and upon exit it is turned off again. Notice that all instructions being introduced are being placed in cold blocks. The code generated for our example is given in Fig. 4d. We assume that we have 2 integer adders and 2 integer multipliers (floating point units are omitted because we assume all operations in the code are integer operations). Note that at the beginning we turn all functional units off except the integer adder which represents the minimal configuration for this example. The loop represents a hot power block and the block preceding the loop is a cold block. Therefore we introduce instructions according to the requirements of the power block prior to entering it. Since the hot basic block in the loop containing the statements ”sum = sum+arr[i]” and ”i++” requires two adders to exploit ILP, we turn on an additional adder before entering the loop. Notice that the multiplier (which we assume also performs the divide operation) is off in the loop. Therefore if we enter the cold block containing the statement ”sum = sum/1000”, a multiplier is turned on and upon exit it is turned off. Finally, prior to executing the library function call for printf all off units are turned on – since at this point adders are already on, only the multipliers need to be turned on.
4
Experimental Results
Implementation. We have implemented and evaluated the techniques described in this paper. We used the lcc [3] compiler for our work. lburg was used to produce code generator from compact specifications. The original code was executed on test data to generate profile information which is used by the compiler to generate on and off instructions. We use a cycle level simulator generated using the FAST [8] system. FAST generates a cycle level simulator, an assembler and a disassembler from a microarchitecture and instruction set specifications. In our experiments we simulated a superscalar that supported out-of-order execution and consisted of 2 integer adders, 2 integer multipliers, 1 floating point
272
Siddharth Rele et al.
adder, and 1 floating point multiplier. It uses control speculation (i.e. branch prediction) and implements a precise exception model using a reorder buffer and a future file. The number of outstanding branches is not limited and branch mispredictions take a variable number of cycles to recover. We used six benchmarks in our experiments. From Mediabench [6] we have used two programs: rawcaudio.c and rawdaudio.c. From DSPstones we have taken three programs: fir2dim.c, n-real-updates.c, and fir.c. The last benchmark, compress.c, is from SPEC95. Removing idle time. To access the effectiveness of our idle time removal technique we measured the utilization of functional units before and after optimization. We define utilization as the percentage of total program execution time (in cycles) for which the unit is on and busy executing instructions. In Table 2 we show the utilization of the various functional unit types in the processor – for integer units the numbers represent average utilization of the two units. As we can see, except for the integer adders, the other units have very low utilization because while they are on, they are often not executing any operations. In other words there must be times when these units can be turned off. After applying our techniques we measured the utilization again. As shown in Table 3 the utilization of the integer adders shows very little change. This is because during the execution of the optimized code these units were always on. For the other three types of units the utilization has become very high because they are busy executing operations while they are on. This means that for most of the times that they were idle, we were able to turn them off. In other words these units were off for over 90% of the time for all programs except compress. Recalling the data in Table 2, we can see that turning off units for 90% of the time results in significant savings in static power dissipation.
Table 2. Utilization of functional units in original code
Benchmark rawcaudio.c rawdaudio.c fir2dim.c n-real-updates.c fir.c compress.c
Utilization (%) Integer Float Adder M ult Adder M ult 87.71 0.0252 0 0 88.76 0.00159 0 0 59.45 7.01 0 0 61.62 2.37 0 0 52.26 2.65 0 0 90.08 0.045 25.70 29.06
Performance degradation. We also measured the degradation in the performance by comparing the total execution cycle counts for original and optimized code (see Table 4). The degradation is less than 1% due to the fact that we place
Optimizing Static Power Dissipation by Functional Units
273
Table 3. Utilization of functional units in optimized code
Benchmark rawcaudio.c rawdaudio.c fir2dim.c n-real-updates.c fir.c compress.c
Utilization (%) Integer Float Adder M ult Adder M ul 87.73 99.7 99.7 99.7 88.77 99.76 99.76 99.76 59.73 85.03 99.70 99.70 61.62 94.53 99.44 99.44 52.72 92.42 99.38 99.38 90.31 98.90 23.99 51.15
Table 4. Performance degradation Benchmark rawcaudio.c rawdaudio.c fir2dim.c n-real-updates.c fir.c compress.c
U noptimized Optimized 6,588,776 6,591,742 5,028,710 5,049,175 4,676 4689 2,697 2,697 2,413 2,424 453,823 454,877
Degradation -0.0147 -0.0041 -0.28 0 -0.46 -0.232
the on and off instructions in cold blocks and units are turned on upon decode of instruction with the on suffix. The latter action reduces stalling of instructions due to unavailability of functional units. Transition activity vs off durations. For each idle period that a unit is turned off, we have a pair of transitions: on-to-off and then off-to-on. While the static power saved during the off periods depends upon the duration of the off periods, the dynamic power spent during transitions depends upon the total number of transitions actually performed. Table 5 gives the total number of transition pairs for all the functional units types. There are no transitions for integer adders because they are always on and for integer multipliers the number given is the sum of the transitions encountered by both units of this type. These are the transitions which were actually performed during execution. Table 6 gives the average duration for which units were turned off. As we can see these durations are quite long - ranging from several hundred to several thousand cycles. Since the durations for which functional units are off are quite long and the number of transition pairs is relatively modest, we can conclude that our approach is quite effective in saving static power wasted by idle functional units. Effectiveness of nullification strategy. We also measured the number of transition pairs which were nullified by our architecture design because they
274
Siddharth Rele et al.
Table 5. Non-nullified transition pairs
Benchmark rawcaudio.c rawdaudio.c fir2dim.c n-real-updates.c fir.c compress.c
Integer Float Adder M ult Adder M ult 0 769 748 735 0 800 919 712 0 2 1 1 0 2 1 1 0 2 1 1 0 113 212 286
Table 6. Average off duration in cycles
Benchmarks rawcaudio.c rawdaudio.c fir2dim.c n-real-updates.c fir.c compress
Integer Float Adder M ult Adder M ult 8552 8789 8944 5481 6296 7075 3987 4674 4674 2550 2682 2682 2230 2409 2409 10929 496 847
Table 7. Nullified transition pairs
Benchmarks rawcaudio.c rawdaudio.c fir2dim.c n-real-updates.c fir.c compress
Integer Float Adder M ult Adder M ult 0 445 148 148 1510 298 149 149 0 0 0 0 0 0 0 0 2 0 0 0 958 0 1539 0
were too close together. The number of nullified transition pairs is given in Table 7. As we can see, this number is quite significant for some benchmarks as they contain variable length idle regions which are quite often of small duration. Therefore our approach of allowing the compiler to aggressively remove idle time and then relying on the hardware to nullify the operations if they are not useful has proven to be very successful.
Optimizing Static Power Dissipation by Functional Units
5
275
Conclusions
The static power component of power dissipation is on a rise [2,9]. We presented a technique for reducing this static power to some extent by switching off the idle units. Our approach uses a combination of compiler, instruction set, and microarchitecture support for maximizing power savings and minimizing performance degradation. Static power reduction of over 90% was achieved for units that were found to be mostly idle at the cost of well under 1% increase in execution times.
References 1. D. Brooks, V. Tiwari, and M. Martonosi. Wattch: A Framework for ArchitecturalLevel Power Analysis and Optimizations. In International Symposium on Computer Architecture (ISCA), pages 83–94, Vancouver, British Columbia, June 2000. 2. J. A. Butts and G. S. Sohi. A Static Power Model for Architects. In IEEE/ACM International Symposium on Microarchitecture (MICRO), pages 191–201. December 2000. 261, 262, 263, 275 3. C. Fraser and D. Hanson. lcc: A Retargetable C Compiler: Design and Implementation. Adison Wesley Publishing Company, 1995. 271 4. M. Horowitz, T. Indermaur, and R. Gonzalez. Low-Power Digital Design. In IEEE Symposium on Low Power Electronics, pages 8-11, 1994. 261 5. K. S. Khouri and N. K. Jha. Private Communication. June 2001. 262 6. C. Lee, M. Potkonjak, and W. H. Mangione-Smith. Mediabench: A tool for evaluating and synthesizing multimedia and communications systems. In IEEE/ACM International Symposium on Microarchitecture (MICRO), Research Triangle Park, North Carolina, December 1997. 272 7. MIPS Technologies, 1225 Charleston Road, Mountain View CA-94043. MIPS32 4k Processor Core Family, Software Users Manual, 1.12 edition, January 2001. 8. S. Onder and R. Gupta. Automatic Generation of Microarchitecture Simulators. In IEEE International Conference on Computer Languages (ICCL), pages 80–89, Chicago, Illinois, May 1998. 264, 271 9. M. D. Powell, S-H. Yang, B. Falsafi, K. Roy, and T. N. Vijaykumar. GatedVdd:a Circuit Technique to Reduce Leakage in Deep-Submicron Cache Memories. In ACM/IEEE International Symposium on Low Power Electronics and Design (ISLPED), 2000. 261, 262, 275 10. K. Roy. Leakage Power Reduction in Low-Voltage CMOS Design. In IEEE International Conference on Circuits and Systems, pages 167-173, 1998. 262 11. S. Thompson, P. Packan, and M. Bohr. MOS Scaling: Transistor Challenges of the 21st Century. Intel Technology Journal, Q3, 1998. 262 12. V. Tiwari, R. Donnelly, S. Malik, and R. Gonzalez. Dynamic Power Management for Microprocessors: A Case Study. In International Conference on VLSI Design, pages 185-192, 1997. 261 13. V. Tiwari, D. Singh, S. Rajgopal, G. Mehta, R. patel, and F. Baez. Reducing Power in High-Performance Processors. In Design Automation Conference (DAC), pages 732-737, 1998. 261 14. Q. Wang and S. Vrudhula. Static Power Optimization of Deep Submicron CMOS Circuits for Dual VT Technology. In International Conference on Computer-Aided Design (ICCAD), pages 490-496, 1998. 262
Influence of Loop Optimizations on Energy Consumption of Multi-bank Memory Systems Mahmut Kandemir1 , Ibrahim Kolcu2 , and Ismail Kadayif1 1 Department of Computer Science and Engineering The Pennsylvania State University, University Park, PA 16802, USA
[email protected] 2 Computation Department, UMIST Manchester, M60 1QD, UK
[email protected] Abstract. It is clear that automatic compiler support for energy optimization can lead to better embedded system implementations with reduced design time and cost. Efficient solutions to energy optimization problems are particularly important for array-dominated applications that spend a significant portion of their energy budget in executing memory-related operations. Recent interest in multi-bank memory architectures and low-power operating modes motivates us to investigate whether current locality-oriented loop-level transformations are suitable from an energy perspective in a multi-bank architecture, and if not, how these transformations can be tuned to take into account the banked nature of the memory structure and the existence of low-power modes. In this paper, we discuss the similarities and conflicts between two complementary objectives, namely, optimizing cache locality and reducing memory system energy, and try to see whether loop transformations developed for the former objective can also be used for the latter. To test our approach, we have implemented bank-conscious versions of three loop transformation techniques (loop fission/fusion, linear loop transformations, and loop tiling) using an experimental compiler infrastructure, and measured the energy benefits using nine array-dominated codes. Our results show that the modified (memory bank-aware) loop transformations result in large energy savings in both cacheless and cache-based systems, and that the execution times of the resulting codes are competitive with those obtained using pure locality-oriented techniques in a cache-based system.
1
Introduction
In programming for many embedded devices, one important aspect is to minimize the energy consumption. As off-chip main memories incur a significant energy and performance penalty when accessed, it is particularly important to perform user and/or compiler level optimizations to reduce energy consumption R. N. Horspool (Ed.): CC 2002, LNCS 2304, pp. 276–292, 2002. c Springer-Verlag Berlin Heidelberg 2002
Influence of Loop Optimizations on Energy Consumption
277
and improve cache locality (if a cache exists in the system). While the impact of loop-level compiler optimizations on performance is well understood (e.g., see [12] and the references therein), very few studies (e.g., [1]) have tried to address the effect of these transformations on energy consumption. Investigating the energy impact of loop optimizations is important, because this is the first step towards developing energy-oriented compiler optimizations. Improving memory energy consumption is particularly important in embedded systems that execute image and video processing applications. These applications manipulate large arrays of signals using nested loops, and spend significant portions of their execution time in executing memory-related operations [1]. Large off-chip memories that hold the arrays manipulated by these codes exhibit high per access energy cost (due to long bitlines and wordlines). A recent trend in memory architecture design is to organize the memory as an array of multiple banks (e.g., [11]) instead of a more traditional monolithic single-bank architecture. Each bank contains a portion of the address space and can be optimized for energy using an appropriate mix of low-power operating modes. More specifically, a bank not used by the current computation can be placed into a low-power operating mode. Also, using smaller banks help reduce per access energy cost. Recent work has addressed how such low-power operating modes can be managed at software [3,6] and hardware [3] levels. The impact of array placement strategies and two loop optimizations (loop splitting and loop distribution) on a banked off-chip memory architecture has been presented in [2]. The focus of this paper is on reducing the energy consumption of a multi-bank memory system without sacrificing performance significantly. In particular, we focus on array-dominated applications that can be found in domains such as embedded image/video processing and scientific computing, and investigate several loop transformation techniques to see whether they are successful in reducing the memory system energy. We address the problem for both a cacheless system and a system with cache memory. In a cacheless system (which is used commonly in real-time embedded applications), we study the energy impact of classical locality-oriented loop-level techniques and show that slight modifications to them can bring large energy benefits. In a cache-based system, we attempt to modify the data locality-oriented techniques to take into account the banked nature of the off-chip memory. To test our approach, we have implemented bankconscious versions of three loop transformation techniques (loop fission/fusion, linear loop transformations, and loop tiling) using the SUIF compiler infrastructure [5], and measured the energy benefits using nine array-dominated codes. Our results show that the modified loop transformations result in large energy savings, and that the execution times of the resulting codes are competitive with those obtained using pure locality-oriented techniques. The rest of this paper is organized as follows. Section 2 introduces the memory architecture assumed, and revises the fundamental concepts related to lowpower operating mode management. Section 3 discusses the relationship between cache locality and memory energy consumption. Section 4 discusses the impact of three different loop-level transformations (iteration space tiling, linear loop
278
Mahmut Kandemir et al.
transformations, and loop fusion and fission) on memory energy, and explains how these optimizations can be modified to take into account the banked nature of the memory system. Section 5 presents experimental results showing the energy benefits of loop transformations. Section 6 concludes the paper with a summary.
2
Memory Architecture
In this work, we focus on an RDRAM-like off-chip memory architecture [11] where off-chip memory is partitioned into several banks, each of which can be activated or deactivated independently from others. In this architecture, when a bank is not actively used, it can be placed into a low-power operating mode. While in a low-power mode, a bank typically consumes much less energy than in active (normal operation) mode. However, when the bank is asked to service a memory request, it will take some time for the bank to come alive. The time it takes to switch to active mode (from a low-power mode) is called resynchronization overhead (or reactivation cost). Typically, there is a trade-off between energy saving and resynchronization overhead. That is, a more energy-saving low-power operating mode has also a higher resynchronization overhead. Thus, it is important to select the most appropriate low-power mode to switch to when the bank becomes idle. Note that different banks can be in different low-power modes at a given time. In this study, we assume four different operating modes: an active mode (the mode during which the memory read/write activity can occur) and three lowpower modes, namely, standby, napping, and power-down. Current DRAMs [11] support up to six power modes with a few of them supporting only two modes. We collapse the read, write, and active without read or write modes into a single mode (called active mode) in our experimentation. However, one may choose to vary the number of modes based on the target DRAM architecture. The energy consumptions and resynchronization overheads for these operating modes are given in Figure 1. The energy values shown in this figure have been obtained from the measured current values associated with memory modules documented in memory data sheets (for a 3.3 V, 2.5 nsec cycle time, 8 MB memory) [10]. The resynchronization times (overheads) are also obtained from data sheets. Based on trends gleaned from data sheets, the energy values are increased by 30% when module size is doubled. An important parameter that helps us choose the most suitable low-power mode is bank inter-access time (BIT), i.e., the time between successive accesses (requests) to a given bank. Obviously, the larger the BIT, the more aggressive low-power mode can be exploited. Then, the problem of effective power mode utilization can be defined as one of accurately estimating the BIT and using this information to select the most suitable low-power mode. This estimation can be done by software using the compiler [3,2] or OS support [6], by hardware using a prediction mechanism attached to the memory controller [3], or by a combination of both. While the compiler-based techniques have the advantage of predicting
Influence of Loop Optimizations on Energy Consumption
279
Energy Resynchronization Consumption (nJ) Overhead (cycles) Active 3.570 0 Standby 0.830 2 Napping 0.320 30 Power-Down 0.005 9,000
Fig. 1. Energy consumptions (per access) and resynchronization times for different operating modes. These are the values used in our experiments
BIT accurately for a specific class of applications, runtime and hardware based techniques are able to capture runtime variations in access patterns (e.g., those due to cache hits/misses) better. In this paper, we employ a hardware-based BIT prediction mechanism whose details are explained in [3]. The prediction mechanism is similar to the mechanisms used in current memory controllers. Specifically, after 10 cycles of idleness, the corresponding bank is put in standby mode. Subsequently, if the bank is not referenced for another 100 cycles, it is transitioned into the napping mode. Finally, if the bank is not referenced for a further 1,000,000 cycles, it is put into power-down mode. Whenever the bank is referenced, it is brought back into the active mode incurring the corresponding resynchronization overhead (based on what mode it was in). We focus on a single program environment, and do not consider the existence of a virtual memory system. Exploring the (memory) energy impact of loop transformations in the presence of a virtual address translation is part of our future planned research.
3
Cache Locality vs. Off-Chip Memory Energy
Many optimizing compilers from industry and academia use a suite of techniques for enhancing data locality. Loop transformation techniques [12] are particularly important as there is a well-defined data dependence and loop re-writing (code re-structuring) theory behind them and several efficient implementations exist. Almost all of compiler-based locality-enhancing techniques take some cache specific parameters (e.g., size and associativity) into account and introduce some extra loop overhead and might cause some degradation in the instruction cache performance (as they typically increase code size and reduce instruction reuse). If there exists no cache in the memory hierarchy, it might not be advisable to employ locality-oriented loop transformations as they do not bring any benefit; instead, they increase loop execution overhead. However, if the memory system is partitioned into banks, applying loop transformations still makes sense (i.e., even if there is no cache) as we can cluster loop iterations (through loop transformations) such that the memory accesses in a given time period are localized into a small set of banks. This obviously allows the system to place more banks into low-power operating modes. One of the questions that we try to address in this
280
Mahmut Kandemir et al.
paper is to see whether the classical cache locality oriented techniques are also suitable for optimizing off-chip memory energy in a cacheless multi-bank memory architecture; and if so, how they can be modified to extract the maximum energy benefits from the memory system. The existence of a cache memory can, on the other hand, have an important impact on the energy consumption of a banked memory architecture. The cache memory can filter out many memory references and increase the bank interaccess times. This has two major consequences. First, the off-chip memory is accessed less frequently, and therefore consumes less energy. Second, more memory banks can be put in low-power modes and (in some cases) more aggressive low-power modes can be utilized. If the banked-memory system has a cache memory, selecting a suitable combination and versions of loop-level transformations to apply becomes a much more challenging problem. This is because, two objectives, namely, optimizing cache locality and minimizing off-chip memory energy can sometimes conflict with each other (that is, they may demand different loop transformations and/or different parameters–e.g., tile size and unrolling factor–for the same set of transformations). In this case, one approach would be to optimize cache locality only and not to perform any banked-memory specific transformation. This strategy works fine as long as the cache is able to capture the data access pattern successfully; that is, the vast majority of data references are satisfied from the cache and do not go to off-chip memory. However, if this is not the case, then we need to take care of off-chip references as well. We address this problem by modifying the cache locality optimization strategy to take into account the fact that, for the best off-chip energy behavior, the data accesses should be clustered into a small set of memory banks. More specifically, we modify each type of loop transformation so that it becomes bank-conscious (bank-aware) as explained in the next section. One way of achieving this is to make sure that the transformed code accesses fewer banks than the original (unoptimized) code (even if all accesses miss the cache) and that the accesses are more clustered than the original code. If this is not possible, then we try not to increase the number of banks that need to be activated (as compared to the original code). In addition to evaluating the impact of loop transformations on the energy behavior of a cacheless memory architecture, this paper also experimentally evaluates two alternative schemes for optimizing energy and locality for a banked memory architecture with cache. The first scheme optimizes only for cache locality, and the second scheme tries to strike a balance between enhancing cache locality and reducing off-chip memory energy as explained above.
4
Energy Impact of Loop Transformations
In this section, we discuss how classical loop-based techniques developed for optimizing cache locality affect off-chip memory energy consumption. The conclusions we make here will be supported by experimental evaluation given in Section 5. As mentioned earlier in the paper, the optimizations considered in
Influence of Loop Optimizations on Energy Consumption
281
this work include loop fusion/fission, iteration space tiling (loop blocking), and linear loop transformations. 4.1
Loop Fusion and Fission
Combining two loops into a single loop is called loop fusion. It is traditionally used to bring array references to the same elements close together [12]. Consider the following example written using a C-like notation, which consists of two separate loops that access the same array a. It is easy to see that if the loop limit is sufficiently large that the array does not fit in cache, this code will stream the array a from memory through the cache twice (once for each loop). for(i=0;i