August 31–September 2, 2009 Edinburgh, Scotland
ICFP’09 Proceedings of the 2009 ACM SIGPLAN
International Conference on Functional Programming Sponsored by:
ACM SIGPLAN Supported by:
Credit Suisse, Galois, Jane Street Capital, Microsoft Research, & Standard Chartered
The Association for Computing Machinery 2 Penn Plaza, Suite 701 New York, New York 10121-0701 Copyright © 2009 by the Association for Computing Machinery, Inc. (ACM). Permission to make digital or hard copies of portions of this work for personal or classroom use is granted without fee provided that copies are not made or distributed for profit or commercial advantage and that copies bear this notice and the full citation on the first page. Copyright for components of this work owned by others than ACM must be honored. Abstracting with credit is permitted. To copy otherwise, to republish, to post on servers or to redistribute to lists, requires prior specific permission and/or a fee. Request permission to republish from: Publications Dept., ACM, Inc. Fax +1 (212) 869-0481 or . For other copying of articles that carry a code at the bottom of the first or last page, copying is permitted provided that the per-copy fee indicated in the code is paid through the Copyright Clearance Center, 222 Rosewood Drive, Danvers, MA 01923. Notice to Past Authors of ACM-Published Articles ACM intends to create a complete electronic archive of all articles and/or other material previously published by ACM. If you have written a work that has been previously published by ACM in any journal or conference proceedings prior to 1978, or any SIG Newsletter at any time, and you do NOT want this work to appear in the ACM Digital Library, please inform
[email protected], stating the title of the work, the author(s), and where and when published.
ISBN: 978-1-60558-332-7 Additional copies may be ordered prepaid from:
ACM Order Department PO Box 30777 New York, NY 10087-0777, USA Phone: 1-800-342-6626 (US and Canada) +1-212-626-0500 (Global) Fax: +1-212-944-1318 E-mail:
[email protected] Hours of Operation: 8:30 am – 4:30 pm ET
ACM Order Number 565090 Printed in the USA
ii
Foreword This volume contains the papers presented at ICFP 2009, the 14th ACM SIGPLAN International Conference on Functional Programming, held August 31–September 2, 2009 in Edinburgh, Scotland. ICFP provides a forum for researchers and practitioners to hear about the latest developments in the art and science of functional programming. The 2009 call for papers solicited submissions on topics including language design, implementation, software-development techniques, foundations, transformation and analysis, applications, and domain-specific languages, as well as functional pearls and short experience reports. ICFP welcomes work on all languages that encourage functional programming, including both purely applicative and imperative languages, as well as languages with objects or concurrency. In response to the call for papers, the program committee received 101 submissions: 85 regular papers (including 8 pearls) and 16 experience reports. From these, the program committee selected 26 regular papers and 6 experience reports for presentation at the conference. Regular papers were evaluated according to their relevance, correctness, significance, originality, and clarity. Pearls were evaluated similarly, except that they were not required to report original research, but were expected to be concise, instructive, and entertaining. Regular papers and pearl submissions were strictly limited to twelve pages. Experience reports were evaluated separately from regular papers. An experience report is not expected to add to the body of knowledge of the functional-programming community. Rather, it should extend the body of published, refereed, citable evidence that functional programming really works—or describe what obstacles prevent it from working. Each experience report is labeled as such in its title, and an experience report is shorter than a full paper: submissions were restricted to four pages, and final versions to six pages. Each of the submissions was assigned to at least three members of the program committee. Committee members were allowed to find external reviewers for their assigned submissions, but they were required in all cases to read the papers themselves and to form their own opinions. Initial reviews were made available to authors, who had a two-day period in which to respond. Further online discussion by the program committee followed. Final decisions were made at a two-day committee meeting held in Portland, Oregon and attended in person by all but one committee member, who participated by telephone. Reviewers disqualified themselves from reviewing any papers for which they had a conflict of interest, according to a set of strict criteria. Program committee members with a conflict of interest for a paper were prevented from seeing any reviews or online discussion for that paper and were required to leave the room during its live discussion. There were three submissions by members of the program committee, one of which was accepted. These submissions were discussed after all other papers and were held to a higher standard than the other papers. I have felt very honored to be entrusted with the task of organizing this year’s ICFP program, and I am extremely grateful to the many people who have helped make the process run smoothly. I received invaluable guidance and support from ICFP General Chair Graham Hutton; past Program Chairs Norman Ramsey, Kathleen Fisher, and Peter Thiemann; and the other members of the ICFP Steering Committee, especially Jim Hook. Rich Gerber and Paolo Gai of Softconf provided prompt and effective support for their START submission system. Andrew McCreight gave essential assistance during the PC meeting. It has been a privilege to work with the members of the program committee, who dedicated a tremendous amount of time and trouble to the tasks of reviewing and deliberating (and who traveled to the PC meeting at their own expense). Many thanks are also due to the external reviewers. Finally, on behalf of everyone involved in organizing the conference, I thank all the authors who submitted papers. Your continued participation is what makes ICFP possible and worthwhile.
Andrew Tolmach ICFP 2009 Program Chair Portland State University
iii
Table of Contents ICFP 2009 Conference Organization ..................................................................................................ix Invited Talk •
Organizing Functional Code for Parallel Execution or, foldl and foldr Considered Slightly Harmful.........................................................................................1 Guy L. Steele Jr. (Sun Microsystems Laboratories)
Session 1 •
Functional Pearl: La Tour D’Hanoï.............................................................................................................3 Ralf Hinze (University of Oxford)
•
Purely Functional Lazy Non-deterministic Programming ......................................................................11 Sebastian Fischer (Christian-Albrechts University), Oleg Kiselyov (FNMOC), Chung-chieh Shan (Rutgers University)
Session 2 •
Safe Functional Reactive Programming through Dependent Types......................................................23 Neil Sculthorpe, Henrik Nilsson (University of Nottingham)
•
Causal Commutative Arrows and Their Optimization............................................................................35 Hai Liu, Eric Cheng, Paul Hudak (Yale University)
Session 3 •
A Functional I/O System or, Fun for Freshman Kids..............................................................................47 Matthias Felleisen (Northeastern University), Robert Bruce Findler (Northwestern University), Matthew Flatt (University of Utah), Shriram Krishnamurthi (Brown University)
•
Experience Report: Embedded, Parallel Computer-Vision with a Functional DSL............................59 Ryan R. Newton (Massachusetts Institute of Technology), Teresa Ko (University of California at Los Angeles)
•
Runtime Support for Multicore Haskell....................................................................................................65 Simon Marlow, Simon Peyton Jones, Satnam Singh (Microsoft Research)
Session 4 •
Effective Interactive Proofs for Higher-Order Imperative Programs ...................................................79 Adam Chlipala, Gregory Malecha, Greg Morrisett, Avraham Shinnar, Ryan Wisnesky (Harvard University)
•
Experience Report: seL4: Formally Verifying a High-Performance Microkernal............................................................................91 Gerwin Klein (NICTA & University of New South Wales), Philip Derrin (NICTA), Kevin Elphinstone (NICTA & University of New South Wales)
•
Biorthogonality, Step-Indexing and Compiler Correctness ....................................................................97 Nick Benton (Microsoft Research), Chung-Kil Hur (University of Cambridge)
Session 5 •
Scribble: Closing the Book on Ad Hoc Documentation Tools...............................................................109 Matthew Flatt (University of Utah), Eli Barzilay (Northeastern University), Robert Bruce Findler (Northwestern University)
v
Invited Talk •
Lambda, the Ultimate TA: Using a Proof Assistant to Teach Programming Language Foundations .....................................................................................121 Benjamin C. Pierce (University of Pennsylvania)
Session 6 •
A Universe of Binding and Computation.................................................................................................123 Daniel R. Licata, Robert Harper (Carnegie Mellon University)
•
Non-Parametric Parametricity .................................................................................................................135 Georg Neis, Derek Dreyer, Andreas Rossberg (MPI-SWS)
Session 7 •
Finding Race Conditions in Erlang with QuickCheck and PULSE .....................................................149 Koen Claessen, Michał Pałka, Nicholas Smallbone (Chalmers University of Technology), John Hughes, Hans Svensson, Thomas Arts (Chalmers University of Technology and Quviq AB), Ulf Wiger (Erlang Training and Consulting)
•
Partial Memoization of Concurrency and Communication ..................................................................161 Lukasz Ziarek, KC Sivaramakrishnan, Suresh Jagannathan (Purdue University)
Session 8 •
Free Theorems Involving Type Constructor Classes: Functional Pearl..............................................173 Janis Voigtländer (Technische Universität Dresden)
•
Experience Report: Haskell in the “Real World”: Writing a Commercial Application in a Lazy Functional Language ...................................................185 Curt J. Sampson (Starling Software)
•
Beautiful Differentiation ............................................................................................................................191 Conal M. Elliott (LambdaPix)
Session 9 •
OXenstored: An Efficient Hierarchical and Transactional Database using Functional Programming with Reference Cell Comparisons .....................................................203 Thomas Gazagnaire, Vincent Hanquez (Citrix Systems)
•
Experience Report: Using Objective Caml to Develop Safety-Critical Embedded Tools in a Certification Framework .....................................................................................215 Bruno Pagano, Olivier Andrieu, Thomas Moniot (Esterel Technologies), Benjamin Canou, Emmanuel Chailloux, Philippe Wang, Pascal Manoury (Université Pierre et Marie Curie), Jean-Louis Colaço (Prover Technologies S.A.S)
•
Identifying Query Incompatibilities with Evolving XML Schemas .....................................................221 Pierre Genevès (CNRS), Nabil Layaïda, Vincent Quint (INRIA)
Invited Talk •
Commutative Monads, Diagrams and Knots ..........................................................................................231 Dan Piponi (Industrial Light & Magic)
Session 11 •
Generic Programming with Fixed Points for Mutually Recursive Datatypes ....................................233 Alexey Rodriguez Yakushev (Vector Fabrics B.V.), Stefan Holdermans, Andres Löh (Utrecht University), Johan Jeuring (Utrecht University and Open University of the Netherlands)
•
Attribute Grammars Fly First-Class: How to do Aspect Oriented Programming in Haskell............................................................................245 Marcos Viera (Universidad de la República), S. Doaitse Swierstra (Utrecht University), Wouter Swierstra (Chalmers University of Technology)
vi
Session 12 •
Parallel Concurrent ML ............................................................................................................................257 John Reppy (University of Chicago), Claudio V. Russo (Microsoft Research), Yingqi Xiao (University of Chicago)
•
A Concurrent ML Library in Concurrent Haskell ................................................................................269 Avik Chaudhuri (University of Maryland, College Park)
Session 13 •
Experience Report: OCaml for an Industrial-Strength Static Analysis Framework ........................281 Pascal Cuoq, Julien Signoles, Patrick Baudin, Richard Bonichon, Géraud Canet, Loïc Correnson, Benjamin Monate, Virgile Prevosto, Armand Puccetti (Commissariat à l’Energie Atomique)
•
Control-Flow Analysis of Function Calls and Returns by Abstract Interpretation...........................287 Jan Midtgaard (Roskilde University), Thomas P. Jensen (CNRS)
Session 14 •
Automatically RESTful Web Applications: Marking Modular Serializable Continuations ............299 Jay A. McCarthy (Brigham Young University)
•
Experience Report: Ocsigen, a Web Programming Framework..........................................................311 Vincent Balat, Jérôme Vouillon, Boris Yakobowski (Université Paris Diderot and CNRS)
•
Implementing First-Class Polymorphic Delimited Continuations by a Type-Directed Selective CPS-Transform ........................................................................................317 Tiark Rompf, Ingo Maier, Martin Odersky (École Polytechnique Fédérale de Lausanne)
Session 15 •
A Theory of Typed Coercions and its Applications................................................................................329 Nikhil Swamy (Microsoft Research), Michael Hicks (University of Maryland), Gavin M. Bierman (Microsoft Research)
•
Complete and Decidable Type Inference for GADTs ............................................................................341 Tom Schrijvers (Katholieke Universiteit Leuven), Simon Peyton Jones (Microsoft Research), Martin Sulzmann (Intaris Software GmbH), Dimitrios Vytiniotis (Microsoft Research)
Author Index ................................................................................................................................................353
vii
ICFP 2009 Conference Organization General Chair: Program Chair: Local Arrangements:
Workshops: Developer Track: Programming Contest: Publicity:
Graham Hutton (University of Nottingham) Andrew Tolmach (Portland State University) Philip Wadler (University of Edinburgh) Kevin Hammond (University of St. Andrews) Gregory Michaelson (Heriot–Watt University) Christopher Stone (Harvey Mudd College) Michael Sperber (DeinProgramm) Bryan O’Sullivan (Linden Lab) Michael Sperber (DeinProgramm) Andy Gill (University of Kansas) Matthew Fluet (Rochester Institute of Technology)
Program Committee:
Amal Ahmed (Indiana University) Maria Alpuente (Technical University of Valencia (UPV)) Lennart Augustsson (Standard Chartered Bank) Lars Birkedal (IT University of Copenhagen) Manuel Chakravarty (University of New South Wales) Koen Claessen (Chalmers University of Technology) Marc Feeley (Universit´e de Montr´eal) Andrzej Filinski (University of Copenhagen) Daan Leijen (Microsoft Research) Xavier Leroy (INRIA Paris-Rocquencourt) Conor McBride (University of Strathclyde) Matthew Might (University of Utah) Shin-Cheng Mu (Academia Sinica) Atsushi Ohori (Tohoku University) Kristoffer Rose (IBM Thomas J. Watson Research Center)
Steering Committee:
Amal Ahmed (Indiana Unviersity, USA) Manuel Chakravarty (University of New South Wales, Australia) Robby Findler (Northwestern University, USA) Matthew Fluet (Rochester Institute of Technology, USA) Ralf Hinze (Oxford University, UK), Chair James Hook (Portland State University, USA) Zhenjiang Hu (National Institute of Informatics, Japan) Paul Hudak (Yale University, USA) Graham Hutton (University of Nottingham, UK) Franc¸ois Pottier (INRIA Paris-Rocquencourt, France) Norman Ramsey (Tufts University, USA) Peter Thiemann (Universit¨at Freiburg, Germany) Andrew Tolmach (Portland State University, USA) Philip Wadler (University of Edinburgh, UK) Stephanie Weirich (University of Pennsylvania, USA)
Sponsor:
ix
External Reviewers Mauricio F. Alba-Castro Thorsten Altenkirch Sergio Antoy Jim Apple Michele Baggi Lennart Beringer Jean-Philippe Bernardy Richard Bird Matthias Blume Mathieu Boespflug Rajesh Bordawekar Edwin Brady Alexandre Buisse Rafael Caballero Marco Carbone Jacques Carette James Chapman Arthur Chargu´eraud Juan Chen Kung Chen Liang-Ting Chen James Cheney Adam Chlipala Tyng-Ruey Chuang Will Clinger Alcino Cunha Sharon Curtis Nils Anders Danielsson Olivier Danvy Gabriel Ditu Lucas Dixon Kevin Donnelly Derek Dreyer Conal Elliott Santiago Escobar Moreno Falaschi Marco Antonio Feliu Matthias Felleisen Amy Felty Xinyu Feng
Jean-Christophe Filliˆatre Matthew Fluet Ronald Garcia Jacques Garrigue Neil Ghani Healfdene Goguen Peter Hancock Ralf Hinze Martin Hofmann Liyang Hu John Hughes Jos´e Iborra Patrik Jansson Jacob Johannsen Christophe Joubert Yukiyoshi Kameyama Matt Kaufmann Gabriele Keller Andrew Kennedy Oleg Kiselyov Ralf L¨ammel Roman Leshchinskiy Ann Lilliestr¨om Sam Lindley Andres L¨oh Salvador Lucas Bradley Lucier Stephen Magill Geoffrey Mainland Luc Maranget Simon Marlow Andrew McCreight Trevor McDonell James McKinna Yasuhiko Minamide Neil Mitchell Stefan Monnier Peter Morris Aleksandar Nanevski Ulf Norell
Supporters
x
Michael Norrish Russell O’Connor Nicolas Oury Gordon Pace Michał Pałka Franc¸ois Pottier Mar´ıa Jos´e Ram´ırez-Quintana Yann R´egis-Gianas Benoˆıt Robillard Daniel Romero Jaime S´anchez-Hern´andez Jeffrey Sarnat Peter Sestoft Zhong Shao Josep Silva J´erˆome Sim´eon Satnam Singh Christian Skalka Nicholas Smallbone Dan Spoonhower Don Stewart Christopher Stone Kasper Svendsen Joel Svensson Wouter Swierstra Salvador Tamarit Peter Thiemann Phil Trinder Alicia Villanueva Lionel Villard Janis Voigtl¨ander Dimitrios Vytiniotis Geoffrey Washburn Shu-Chun Weng Simon Winwood Limsoon Wong Yingqi Xiao Ian Zerny
Organizing Functional Code for Parallel Execution or, foldl and foldr Considered Slightly Harmful Guy L. Steele Jr. Sun Microsystems Laboratories Burlington, Massachusetts
[email protected]
Abstract In this talk I will discuss three ideas (none original with me) that I have found to be especially powerful in organizing Fortress programs so that they may be executed equally effectively either sequentially or in parallel: user-defined associative operators (and, more generally, user-defined monoids); conjugate transforms of data; and monoid-caching trees (as described, for example, by Hinze and Paterson). I will exhibit pleasant little code examples (some original with me) that make use of these ideas.
Alan Perlis, inverting Oscar Wilde’s famous quip about cynics, once suggested, decades ago, that a Lisp programmer is one who knows the value of everything and the cost of nothing. Now that the conference on Lisp and Functional Programming has become ICFP, some may think that OCaml and Haskell programmers have inherited this (now undeserved) epigram. I do believe that as multicore processors are becoming prominent, and soon ubiquitous, it behooves all programmers to rethink their programming style, strategies, and tactics, so that their code may have excellent performance. For the last six years I have been part of a team working on a programming language, Fortress, that has borrowed ideas not only from Fortran, not only from Java, not only from Algol and Alphard and CLU, not only from MADCAP and MODCAP and MIRFAC and the Klerer-May system—but also from Haskell, and I would like to repay the favor.
Categories and Subject Descriptors D.3.3 [Language Constructs and Features]: Concurrent programming structures General Terms Algorithms, Languages, Performance Keywords reduction, associative operator, monoid, tree, conjugate transform
Copyright is held by Sun Microsystems, Inc. ICFP’09, August 31–September 2, 2009, Edinburgh, Scotland, UK. ACM 978-1-60558-332-7/09/08.
1
Functional Pearl: La Tour D’Hano¨ı Ralf Hinze Computing Laboratory, University of Oxford, Wolfson Building, Parks Road, Oxford, OX1 3QD, England
[email protected]
Abstract
if we arrange them in a circle. Initially, the discs are placed on one peg in decreasing order of diameter. The task is then to move the disks, one at a time, to another peg subject to the rule that a larger disk must not be placed on a smaller one. This restriction implies that a configuration can be represented by a list of pegs: the first element determines the position of the largest disc, the second element the position of the second largest disc, and so forth. Consequently, there are 3n possible configurations where n is the total number of discs. Lucas’ original puzzle contained 8 discs. The instructions of the puzzle refer to an old Indian legend, attributed to the French mathematician De Parville, according to which monks were given the task of moving a total of 64 discs. Since the day of the world’s creation, they transfer the discs, one per day. According to the legend, once they complete their sacred task, the world will come to an end. Now, taking a wholemeal approach, let us first develop the big picture. The set of all configurations together with the set of legal moves defines a graph, which turns out to enjoy a nice inductive definition. If there are no discs to move around, the graph consists of a singleton node: ◦. For the inductive step we reason as follows: the largest disc can only be moved, if all the smaller discs reside on one other peg. The smaller discs, however, can be moved independent of the largest one. As the largest disk may rest on one of three pegs, the graph for n + 1 discs consequently incorporates three copies of the graph for n discs linked by three edges. The diagram in Fig. 1 illustrates the construction. The graph has the shape of a triangle; the dashed lines indicate the sub-graphs (for n = 0 the sub-graphs collapse to singleton nodes); the three solid lines connect the sub-graphs. The inductive construction shows that
This pearl aims to demonstrate the ideas of wholemeal and projective programming using the Towers of Hanoi puzzle as a running example. The puzzle has its own beauty, which we hope to expose along the way. Categories and Subject Descriptors D.1.1 [Programming Techniques]: Applicative (Functional) Programming; D.3.2 [Programming Languages]: Language Classifications—Applicative (functional) languages, Haskell; D.3.3 [Programming Languages]: Language Constructs and Features—Frameworks, patterns, recursion General Terms
Algorithms, Design, Languages
Keywords Towers of Hanoi, wholemeal programming, projective programming, Hanoi graph, Sierpi´nski graph, Sierpi´nski gasket graph, Gray code
1.
Introduction
Functional languages excel at wholemeal programming, a term coined by Geraint Jones. Wholemeal programming means to think big: work with an entire list, rather than a sequence of elements; develop a solution space, rather than an individual solution; imagine a graph, rather than a single path. The wholemeal approach often offers new insights or provides new perspectives on a given problem. It is nicely complemented by the idea of projective programming: first solve a more general problem, then extract the interesting bits and pieces by transforming the general program into more specialised ones. This pearl aims to demonstrate the techniques using the popular Towers of Hanoi puzzle as a running example. This puzzle has its own beauty, which we hope to expose along the way.
2.
BBn
The Hanoi graph
BCn
The Towers of Hanoi puzzle was invented by the French number ´ theorist Edouard Lucas more than a hundred years ago. It consists of three vertical pegs, on which discs of mutually different diameters can be stacked. For reference, we call the pegs A, B and C and let a, b and c range over pegs.
BAn
ACn
AAn
data Peg = A | B | C I own a version of the puzzle where the pegs are arranged in a row. However, the mathematical structure of the puzzle becomes clearer,
CAn
ABn
CBn
CCn
Figure 1. Inductive construction of the Hanoi graph. the graph is planar: it can be drawn so that no edges intersect. Fig. 2 displays the graph for 4 discs. To reduce clutter, the peg of the largest disc is always written in the centre of the respective subgraph, with the size of the font indicating the size of the disc. Can you find the configuration [B, A, B, C]? The corners of the triangle correspond to perfect configurations: all the discs reside on one peg. The example graph shows that every configuration permits three different moves, except for the three perfect configurations, where only two moves are possible.
Permission to make digital or hard copies of all or part of this work for personal or classroom use is granted without fee provided that copies are not made or distributed for profit or commercial advantage and that copies bear this notice and the full citation on the first page. To copy otherwise, to republish, to post on servers or to redistribute to lists, requires prior specific permission and/or a fee. ICFP’09, August 31–September 2, 2009, Edinburgh, Scotland, UK. c 2009 ACM 978-1-60558-332-7/09/08. . . $10.00 Copyright
3
B
B C
A
B
C
A
A A
C B
B
C
A
C
B
A B
C
C
B
C C
A
C A
C
A
B
B
A
B
B
B
A
B
A C
C
A
C
A
C A
A B
C
A
B B
C
B
B
B
A
C
C
A
B
A
B C
A
A
C
B
C
C C
B
B
C B
A
A B
A
A A
C
C
C C
C
A
B A
A
B
C
B A
A
B C
B
C
A
C
C
A C
B
C
B
B B
A
B
B
A
C
A
A A
C
A
B
B
C
Figure 2. The Hanoi graph for 4 discs. Let us turn our attention to the layout of the sub-graphs. The following notation proves to be useful: the arrangement x y z denotes a permutation of x , y and z , which by assumption are pairwise different. Using a b c to indicate the position of the largest disc—the largest disc in the left triangle resides on a and so forth—we observe that if the corners of the graph are a b c , then the corners of the sub-graphs are a c b , c b a and b a c , respectively. Using this observation, we can capture the informal description of the construction as a pseudo-Haskell program.
BBn
BCn
BAn
ACn
AAn
graph 0 (a b c ) = ◦ graph n+1 (a b c ) = b / graph n (c b a ) / \ a / graph n (a c b ) — c / graph n (b a c )
CAn
ABn
CBn
CCn
Figure 3. Change of direction.
The function graph n maps an arrangement a b c to an undirected graph, whose vertices are lists of pegs of length n. The notation a / g means prepend a to all the vertices of g; ◦ denotes a singleton node labelled with the empty list. We leave the type of graphs unspecified. If the type were a functor, then a / g would be fmap (a :) g. The call graph 4 (A B C ) yields the graph in Fig. 2. A few observations are in order. The definition of graph n implies that the graph has 3n nodes (a0 = 1, an+1 = 3 · an ) and (3n+1 − 3)/2 edges (a0 = 0, an+1 = 3 · an + 3). Furthermore, the length of a side of the triangle is 2n −1 (a0 = 0, an+1 = 2·an +1). Since there are only 3! = 6 permutations of three different items, the graph contains at most six different mini-triangles: A B C , C A B , C C B A B A , A B , C A and B C . Note that the first three arrange the pegs clockwise and the last three counterclockwise. Inspecting the definition of graph n , we see that the direction changes with every recursive step. The diagram in Fig. 3 illustrates the change of direction. This observation implies that the recursion pattern is quite regular: at any depth there are only three different recursive calls. Fig. 4 visualises the call structure using three different colours.
3.
Towers of Hanoi, recursively
Solving the Towers of Hanoi puzzle amounts to finding a shortest path in the corresponding graph. Clearly, the shortest path between two corners is along the side of the triangle. Projecting onto the lower side, we transform graph n to hanoi 00 (a b c ) = [[ ]] hanoi 0n+1 (a b c ) = a / hanoi 0n (a c b ) + + c / hanoi 0n (b a c ) . Now, a / x is shorthand for fmap (a :) x . The call hanoi 0n (a b c ) returns a list of configurations solving the problem of ‘moving n discs from a to c using b’. Instead of configurations (vertices of the graph) we can alternatively return a list of moves (corresponding to edges). hanoi 0 (a b c ) = [ ] hanoi n+1 (a b c ) = hanoi n (a c b ) + + [(a, c)] + + hanoi n (b a c )
4
Figure 4. Call structure of hanoi at recursion depth 4. The pair (a, c) represents the instruction ‘move the topmost disc from a to c’. Here is the sequence of moves for n = 4:
tract his work plan from hanoi n by omitting all the moves, except when n equals 1.
> > hanoi 4 (A B C ) [(A, B), (A, C), (B, C), (A, B), (C, A), (C, B), (A, B), (A, C), (B, C), (B, A), (C, A), (B, C), (A, B), (A, C), (B, C)] .
cycle 0 (a b c ) = [ ] cycle 1 (a b c ) = [(a, c)] cycle n+1 (a b c ) = cycle n (a c b ) + + cycle n (b a c )
Note that the lower part of the list can be obtained from the upper part via a clockwise rotation of the pegs: a c b becomes b a c . There is at least one further variation: instead of an arrangement one can pass a source and a target peg.
The function is called cycle , because the smallest disc cycles around the pegs: in the recursive call it is moved from a to b and then from b to c. Whether it cycles clockwise or counterclockwise depends on the parity of n—the direction changes with every recursive call of cycle . Of course, the smallest disc is by no means special: all the discs cycle around the pegs, albeit at a slower pace and in alternating directions. In fact, hanoi satisfies the following ‘fractal’ property:
hanoi 0 a c = [ ] hanoi n+1 a c = hanoi n a (a ⊥ c) + + [(a, c)] + + hanoi n (a ⊥ c) c The function ⊥, which determines ‘the other peg’, is given by A ⊥ A = A; B ⊥ A = C; C ⊥ A = B;
A ⊥ B = C; B ⊥ B = B; C ⊥ B = A;
hanoi n+1 (a b c ) = cycle n+1 (a b c ) g hanoi n (a b c ) ,
A⊥C=B B⊥C=A C⊥C=C .
where g denotes the interleaving of two lists. [] g bs = bs (a : as) g bs = a : (bs g as)
We will find some use for ⊥ later on. For the moment, we just note that the operation is commutative and idempotent, but not associative.
4.
(1)
The fractal property suggests the following alternative definition of hanoi , which has a strong parallel flavour. phanoi 0 (a b c ) = [ ] phanoi n+1 (a b c ) = cycle n+1 (a b c ) g phanoi n (a b c )
Towers of Hanoi, parallelly
Imagine that the monastery always accommodates as many monks as there are discs. The tallest monk is responsible for moving the largest disc, the second tallest monk for moving the second largest disc, and so forth. Can we set up a work schedule for the monastery? Inspecting Fig. 2, we notice that, somewhat unfairly, the smallest monk is the busiest. Since the smallest triangles correspond to moves of the smallest disc, he is active every other day. We can ex-
In words, the smallest monk starts to work on the first day; he is active every second day and moves his disc, say, clockwise around the pegs. The second smallest monk starts on the second day; he is active every fourth day and moves his disc counterclockwise. And so forth. Actually, only the smallest monk must remember the direction; for the larger discs there is no choice, as one of the other two pegs is blocked by the smallest disc.
5
for any given initial arrangement a b c . This arrangement can be seen as representing the bijective mapping {a 7→ 0, b 7→ 2, c 7→ 1}. Alternatively, we can use an arrangement, say, l t r as a representation of the ‘inverse’ mapping {A 7→ l , B 7→ t, C 7→ r } obtaining the following slightly more succinct variant of pos.
There is an intriguing cross-connection to binary numbers: the activity diagram of the monks (Which monk has to work on a given day?) discs 0 = [ ] discs n+1 = discs n + + [n ] + + discs n yields the binary carry sequence or ruler function as n goes to infinity (Hinze, 2008). This sequence gives the number of trailing zeros in the binary representations of the positive numbers, most significant bit first. Or put differently, it specifies the running time of the binary increment. We will make use of this observation later on. The fractal property (1) enjoys a simple inductive proof, which makes essential use of the following abide law. If x1 and x2 are of the same length, then
pos 0 pos 0 pos 0 pos 0
The two variants are related by pos (A B C ) = pos 0 (0 2 1 ). The latter definition is particularly easy to invert. conf conf conf conf
(x1 + + y1 ) g (x2 + + y2 ) = (x1 g x2 ) + + (y1 g y2 ) . The basis of the induction is straightforward. Here is the inductive step. =
=
= =
(a b c ) [ ] = [] (a b c ) (0 : ps) = a : conf (a c b ) ps (a b c ) (2 : ps) = b : conf (c b a ) ps (a b c ) (1 : ps) = c : conf (b a c ) ps
The call conf (A B C ) [0, 1, 0, 1] yields [A, B, C, A], the configuration we obtain after 5 days. The functions pos 0 and conf are actually identical, if we identify A with 0, B with 2 and C with 1. Then pos 0 is an involutive graph isomorphism between Hanoi graphs and Sierpi´nski graphs of the same order. If the monks have lost track—a 2 appears in the answer list— then we can use the idea of locating a configuration in the Hanoi graph to determine the shortest path to the final configuration cn . This leads to the following generalised version of hanoi .
cycle n+2 (a b c ) g hanoi n+1 (a b c ) { definition of cycle and definition of hanoi } (cycle n+1 (a c b ) + + cycle n+1 (b a c )) g (hanoi n (a c b ) + + [(a, c)] + + hanoi n (b a c )) { abide law } + [(a, c)] (cycle n+1 (a c b ) g hanoi n (a c b )) + + +(cycle n+1 (b a c ) g hanoi n (b a c )) { ex hypothesi } hanoi n+1 (a c b ) + + [(a, c)] + + hanoi n+1 (b a c ) { definition of hanoi }
ghanoi 0 [ ] (a b c ) = [ ] ghanoi n+1 (p : ps) (a b c ) + [(a, c)] + + hanoi n (b a c ) | p a = ghanoi n ps (a c b ) + c + [(b, c)] + + hanoi n (a b c ) | p b = ghanoi n ps (b a ) + b | p c = ghanoi n ps (a c )
hanoi n+2 (a b c ) The abide law is applicable in the second step, because the two + [(a, c)], have the lists, cycle n+1 (a c b ) and hanoi n (a c b ) + same length, namely, 2n . Speaking of the length of lists, note P n i that 2n+1 − 1 = i=0 2 is a simple consequence of the fractal property.
5.
(l t r ) [ ] = [] (l t r ) (A : ps) = l : pos 0 (l r t ) ps (l t r ) (B : ps) = t : pos 0 (r t l ) ps (l t r ) (C : ps) = r : pos 0 (t l r ) ps
(The index n is actually redundant: it always equals the length of the peg list.) Depending on the location of p, we either walk within the left triangle and then along the lower side, or within the upper triangle and then along the right side, or within the right triangle. The reader should convince herself that ghanoi n ps (a b c ) indeed yields the shortest path between ps and c n . Note that the first two equations are perfectly symmetric, in fact, ghanoi n ps (a b c ) = ghanoi n ps (b a c ). As an aside, if we fuse ghanoi with length, then we obtain a function that yields the distance to the final configuration.
When will the world come to an end?
Many visitors come to the monastery. Looking at the configuration of discs, they often wonder how many days have passed since the creation of the world. Or, when will the world come to an end? We can answer these questions by locating the current configuration in the Hanoi graph. If we use as positions the three digits 2 0 1 —that is, 0 for the left triangle, 2 for the upper triangle and 1 for the right triangle—then we obtain the answer to the first question in binary. Fig. 5 displays the Hanoi graph for 4 discs suitably re-labelled—this graph is also known as the Sierpi´nski graph. The 24 nodes on the lower side of the triangle, and only those, are labelled with binary numbers. Consequently, if the current position contains a 2, we know that the monks have lost track. (To answer the second question, we use as positions 1 2 0 instead of 0 2 1 . If we are only interested in the distance to the final configuration, we simply replace the digit 2 by a 1.)
6.
Towers of Hanoi, iteratively
The generalised version of the puzzle—moving from an arbitrary configuration to a perfect configuration—serves as an excellent starting point for the derivation of an iterative solution, that is, a function that maps a configuration to the next move or to the next configuration. The next move is easy to determine: we fuse ghanoi with the natural transformation first :: [α] → Maybe α first [ ] = Nothing first (a : as) = Just a
b
pos (a c ) [ ] = [ ] pos (a b c ) (p : ps) | p a = 0 : pos (a c b ) ps | p b = 2 : pos (c b a ) ps | p c = 1 : pos (b a c ) ps
that maps a list to its first element. We obtain move 0 [ ] (a b c ) = Nothing move n+1 (p : ps) (a b c ) + Just(a, c) + + first(hanoi n (b a c )) | p a = move n ps (a c b ) + c | p b = move n ps (b a ) + + Just(b, c) + + first(hanoi n (a b c )) b | p c = move n ps (a c ) .
For instance, pos (A B C ) [A, B, C, A] yields [0, 1, 0, 1], the binary representation of 5, most significant bit first. The function pos (a b c ) defines a bijection between {A, B, C}n and {0, 2, 1}n
6
2
2 0
1
2
2
2
0 0
1 1
0
1
2
2
2
2 0
1
0
2
0
2
0 0
1
0
0
1
0
2
2
1
0
0
1
0
0
2
2
2
2
0
2
2
1
0 1
1 1
1
1
1
0
2
2
0
0 1
2
2
2
2
2
0 0
0
1
2
1 1
1
2 0
2
0
2
0 0
2
0
1
1 1
0
2
2
1
0
0
1
0
2
2
1 1
0
1
2
1
1
0 1
1
0
2 0
2
2
1
0 1
0
1
1
2
1 1
0
1
Figure 5. The Sierpi´nski graph of order 4. the target pegs explicit. The rows labelled ci lists the target peg for each disc: the first, user-supplied target is c0 = C, the next target is c1 = p0 ⊥ c0 , and so forth.
The operator + + is overloaded to also denote concatenation of Maybe values (+ + is really the mplus method of MonadPlus). (+ +) :: Maybe α → Maybe α → Maybe α Nothing + +m =m Just a + + m = Just a
110
We can simplify the definition of move drastically: since Just a + + m = Just a, the calls to hanoi can be eliminated; because of that the index n is no longer needed. Furthermore, the first two equations can be unified through the use of ⊥, the operator that determines ‘the other peg’. Finally, the second argument, a b c , can be simplified to c, the target peg.
111
i pi ci pi ci
0 A C A C
1 B B B B
2 B B B B
3 A B A B
4 C C C C
5 C C C C
6 C C C C
7 B C C C
A C
The smallest disc is not in place, so it is moved from B to C. In the next round, disc 3 has to be moved from A to B. The smaller discs are moved more frequently, so it is actually prudent to reverse the list of pegs so that the peg on which the smallest disc is located comes first. The situation is similar to the binary increment: visiting the digits from least to most significant bit is more efficient than the other way round. Let us assume for the moment that we know the ‘last target’, the value of c that is discarded in the first equation of move (the pegs that stick out in the example above). Since ⊥ is reversible, p ⊥ c = c 0 iff c = p ⊥ c 0 , we can reconstruct the previous target c from the next target c 0 . The following variant of move makes use of this fact—the suffix ‘i’ indicates that the input list is now arranged in increasing order of diameter. movei [ ] c 0 = Nothing movei (p : ps) c 0 + movei ps (p ⊥ c 0 ) | p 6 (p ⊥ c 0 ) = Just (p, p ⊥ c 0 ) + | p (p ⊥ c 0 ) = movei ps c 0
move [ ] c = Nothing move (p : ps) c = move ps (p ⊥ c) + + Just (p, c) |p6 c |p c = move ps c Here is a short interactive session that illustrates the use of move. > > move [A, B, B, A, C, C, C, B] C Just (B, C) > > move [A, B, B, A, C, C, C, C] C Just (A, B) > > move [C, C, C, C, C, C, C, C] C Nothing Since the pair (B, C) means ‘move the topmost disc from peg B to peg C, the configuration following [A, B, B, A, C, C, C, B] is [A, B, B, A, C, C, C, C]. Incidentally, if we start with the initial peg list [A, A, A, A, A, A, A, A] and target C, we obtain these configurations after 110 and 111 steps. The function move implements a two-way algorithm: on the way down the recursion it calculates the target peg for each disc (using ⊥); on the way up the recursion it determines the smallest disc that is not yet in place (using + +). The following table makes
We consistently changed the second argument of move to reflect the fact that we compute the previous from the next target and additionally replaced the remaining occurrences of c by p ⊥ c 0 . Again, we can simplify the code: p 6 (p ⊥ c 0 ) is the same as p 6 c 0 . Applying Just a + + m = Just a once more, we can eliminate the first recursive call to movei so that the function
7
stops as soon as the smallest displaced disc is found—this was the purpose of the whole exercise. We obtain
Summing up, we obtain the following iterative implementation for solving the generalised Hanoi puzzle. ihanoi ps c = map fst (iterate step (ps, foldr (⊥) c ps)) iterate :: (a → Maybe a) → (a → [a ]) iterate f x = x : case f x of {Nothing → [ ]; Just x 0 → iterate f x 0 }
movei [ ] c 0 = Nothing movei (p : ps) c 0 = Just (p, p ⊥ c 0 ) | p 6 c0 0 |p c = movei ps c 0 .
7.
´ Longest paths and Sierpinski’s triangle
In a nutshell, movei determines the smallest disc that does not reside on the last target. So, for an iterative version of hanoi we have to maintain two pieces of information: the current configuration and the current ‘last target’. It remains to determine the initial last target and how the last target changes after each move. If the list of pegs is given in the original decreasing order, then we can transform move to
So far we have considered shortest paths in the Hanoi graph. Since the destruction of the world hangs in the balance, as a gift to future generations, we might want to look for the longest path. In the following variant of hanoi the largest disc makes a detour. (According to the Indian legend, the temple is actually in Bramah rather than in Hanoi, hence the name of the function.)
last [ ] c=c last (p : ps) c = last ps (p ⊥ c) ,
bramah 0 (a b c ) = [ ] bramah n+1 (a b c ) = bramah n (a b c ) + + [(a, b)] + + bramah n (c b a ) + + [(b, c)] + + bramah n (a b c )
which yields the last target. It is not hard to see that last is an instance of the famous foldl : last ps c = foldl (⊥) c ps. If we reverse the list, we simply have to replace foldl by foldr additionally using the fact that ⊥ is commutative: foldr (⊥) c ps = foldl (⊥) c (reverse ps). Next, we augment movei so that it returns the next configuration instead of the next move and additionally the next ‘last target’.
The largest disc is first moved from a to b, and then from b to c. Since bramah n returns 3n − 1 moves (a0 = 0, an+1 = 3 · an + 2), we have actually found a Hamiltonian path. The path has another interesting property: if the pegs are arranged in a row, a b c, then discs are only moved between adjacent pegs. The Hamiltonian path for four discs is displayed in Fig. 6. The picture is quite appealing. Actually, if we move the sub-triangles closer to each other so that the corners touch, we obtain a nice fractal image. Fig. 7 shows the result for 7 discs. The corresponding graph is known as the discrete Sierpi´nski gasket graph.1 The picture has been drawn using Functional Metapost’s turtle graphics (Korittky, 1998).
step ([ ], c 0 ) = Nothing step (p : ps, c 0 ) | p 6 c0 = Just ((p ⊥ c 0 ) : ps, p ⊥ c 0 ) = do {(ps 0 , c) ← step (ps, c 0 ); | p c0 return (p : ps 0 , p ⊥ c)}
curve 0 d = forward N turn (2 ∗ d ) N forward curve n+1 d = turn d N curve n (−d ) N turn d N curve n d N turn d N curve n (−d ) N turn d
Consider the second equation: p is moved to p ⊥ c 0 . After the move, the disc resides on the target peg. Consequently, the next target is also p ⊥ c 0 —recall that ⊥ is idempotent. This target is then updated in the third equation ‘on the way back’ mimicking move’s mode of operation. The function step runs in constant amortised time, since it performs the same number of steps as the binary increment— recall that the activity diagram of the monks coincides with the binary carry sequence. The next last target can, in fact, be easily calculated by hand. Consider the following two subsequent moves (as usual, a, b and c are pairwise different).
··· ··· ··· ···
n a b b b
n−1 c c c b
n−2 c c c a
··· ··· ··· ···
1 c c c b
0 c c c a
c
The command forward moves the turtle one step forward, turn d turns the turtle clockwise by d degrees, and N sequences two turtle actions. Since turtle graphics is state-based—the turtle has a position and faces a direction—recursive definitions typically maintain an invariant. To draw the ‘triangle’ a b c , we start at a looking at b and stop at c looking away from b. The overall change of direction is twice the second argument of curve , which for equilateral triangles is either 60◦ or −60◦ . The curve is closely related to Sierpi´nski’s arrowhead curve. In fact, both curves yield Sierpi´nski’s triangle as n goes to infinity. As an aside, Sierpi´nski’s triangle is a so-called fractal curve: it has the Hausdorff dimension log 3/ log 2 ∼ = 1.58496, as it consists of three self-similar pieces with magnification factor 2.
b
8.
Gray code
The function bramah enumerates the configurations {A, B, C}n changing only one peg at a time. In other words, the succession of configurations corresponds to a ternary Gray code! To investigate the correspondence a bit further, here is a version of bramah that returns a list of configurations, rather than a list of moves.
Assume that the first configuration ends with an even number of cs. The topmost disc of a is then moved to b. The new succession of target pegs consequently alternates between b and a: since the number of cs is even, the new last target is b; for an odd number, it is a. So, the monks can be instructed as follows: determine the smallest disc that is not on c. Transfer it from a to b. If the disc’s number is even, the new last target is b; otherwise, it is a. If we solve the original puzzle, that is, if the configurations lie on one of the sides of the triangle, then the next last target is even easier to determine: like the discs, it cycles around the pegs. If the pegs are arranged A B C and we move the discs from A to C, then the last target moves counterclockwise for an even number of discs and clockwise for an odd number.
bramah 00 (a b c ) = [[ ]] bramah 0n+1 (a b c ) = a / bramah 0n (a b c ) + + b / bramah 0n (c b a ) + + c / bramah 0n (a b c ) 1 The
term gasket graph is actually not used consistently. Some authors call the counterpart of the Hanoi graph the Sierpi´nski gasket graph, and its contracted variant the Sierpi´nski graph.
8
B
B C
A
B
C
A
A A
C B
B
C
A
C
B
A B
C
C
B
C C
A
C
A A
B
B
B
C
C
A
B
B
B
A
C
C
A
A
C
A
C
A
C
B
A
B
C
C C
B
B
A
B
C
A
A
A
A
B
C
A
B
A
C
B
C
B
B
C
B B
A
C A
A
A
C
C
C
B A
A
A
C
A
B
B B
B
A
C
B A
A
B
B
C
B
B C
C
C
B C
B
C
A
A A
A
C
A
C B
Figure 6. A Hamiltonian path in the Hanoi graph for 4 discs.
Figure 7. A Hamiltonian path in the Hanoi graph for 7 discs.
9
A
B
C
There are two standard ternary Gray codes: the so-called modular code, the digits vary 012|201|120 · · ·, and the reflected code, the digits vary 012|210|012 · · ·. The definition above yields the latter, as the discs are only moved between adjacent pegs. In fact, we have bramah 0n (c b a ) = reverse (bramah 0n (a b c )). Using this property on the second recursive call, we can simplify bramah 0n (0 1 2 ) somewhat. gray3 0 = [[ ]] gray3 n+1 = 0 / gray3 n + + 1 / reverse gray3 n + + 2 / gray3 n
Hanoi graph. From that we derived a series of programs evolving around the Tower of Hanoi theme. Knowing the big picture was jolly useful: for instance, calculating the number of moves could be reduced to the problem of locating a configuration in the graph. Projective program transformations are abundant: hanoi is derived from graph by projecting onto the lower side of the graph, cycle is derived from hanoi by mashing out the moves of the larger discs, and so forth. The transformations could be made rigorous within the Algebra of Programming framework (Bird and de Moor, 1997). Occasionally, this comes at an additional cost. For instance, to derive cycle from hanoi we would additionally need the disc number, which is not present in hanoi ’s output. A lot is left to explore. There are literally hundreds of papers on the subject: Paul Stockmeyer’s comprehensive bibliography has a total of 369 entries (2005). From that bibliography I learned that my definition of the Hanoi graph is not original: Er introduced it to analyse the complexity of the generalised Tower of Hanoi problem (1983). The original instructions of the game already alluded to the recursive procedure for solving the puzzle. It has been used since to illustrate the concept of recursion. The parallel version—usually classified as iterative— is due to Buneman and Levy (1980). Backhouse and Fokkinga (2001) show that each disc cycles around the pegs exploiting the associativity of equivalence. To the best of the author’s knowledge the iterative, or stepwise variant is original. The connection to Gray codes has first been noticed by Gardner (1972).
For comparison, here is the definition of the Gray binary sequence. gray2 0 = [[ ]] gray2 n+1 = 0 / gray2 n + + 1 / reverse gray2 n Actually, the binary Gray code is also hidden in the Tower of Hanoi puzzle. We only have to work with a different configuration space: instead of {A, B, C}n we use {0, 1}n keeping track whether a disc has been moved an even or an odd number of times. The initial configuration AAn becomes 00n , the final configuration CCn becomes 10n . Since the ith disc is moved 2i times, all the discs make an even number of moves, except for the largest, which makes a single move. We can easily adapt ihanoi to generate binary Gray code. We take as a starting point the first definition of movei, slightly modified so that the next configuration is returned instead of the next move (we also applied the Just a + + m = Just a simplification). configi configi |p6 |p
[] c 0 = Nothing (p : ps) c 0 (p ⊥ c 0 ) = Just (p ⊥ c 0 : ps) (p ⊥ c 0 ) = p / configi ps (p ⊥ c 0 )
Acknowledgments Special thanks are due to Daniel James for enjoyable discussions and for suggesting a number of stylistic and presentational improvements. Thanks are also due to the anonymous referees for an effort to make the paper less dense.
Now, the Gray code equivalent of ⊥ is the Boolean operation exclusive or, that is, inequality of Booleans. This implies that the last target c 0 corresponds to a parity bit. Thus, configi becomes (the code uses Booleans rather than bits)
References Backhouse, Roland, and Maarten Fokkinga. 2001. The associativity of equivalence and the Towers of Hanoi problem. Information Processing Letters 77:71–76.
codei [ ] p = Nothing codei (b : bs) p | b 6 (b 6 p) = Just ((b 6 p) : bs) | b (b 6 p) = b / codei bs (b 6 p) .
Bird, Richard, and Oege de Moor. 1997. Algebra of Programming. London: Prentice Hall Europe.
Again, we can simplify the code a bit: inequality and equality of Boolean values are associative (Backhouse and Fokkinga, 2001), so b 6 (b 6 p) is simply p. Using False 6 b = b and True 6 b = ¬ b, we obtain
Buneman, Peter, and Leon Levy. 1980. The Towers of Hanoi problem. Information Processing Letters 10(4–5):243–244. Er, M.C. 1983. An analysis of the generalized Towers of Hanoi problem. BIT 23:429–435.
codei [ ] p = Nothing codei (b : bs) p |p = Just (¬ b : bs) |¬p = b / codei bs b .
Gardner, Martin. 1972. Mathematical games: The curious properties of the Gray code and how it can be used to solve puzzles. Scientific American 227(2):106–109. Reprinted, with Answer, Addendum, and Bibliography, as Chapter 2 of Knotted Doughnuts and Other Mathematical Entertainments, W. H. Freeman and Co., New York, 1986.
In words: we traverse the list p : bs up to the first 1; the following bit, if any, is flipped. As for ihanoi, we have to maintain two pieces of information: the current Gray code and the current parity bit. The latter is easy to update: it is flipped in each step. Summing up, we obtain the following Gray code generator.
Hinze, Ralf. 2008. Functional Pearl: Streams and Unique Fixed Points. In Proceedings of the 2008 International Conference on Functional Programming, ed. Peter Thiemann, 189–200. ACM Press.
igray bs = map fst (iterate step (bs, foldr (6 ) True bs)) where step (bs, p) = do {bs 0 ← codei bs p; return (bs 0 , ¬ p)}
Knuth, Donald E. 2005. The Art of Computer Programming, Volume 4, Fascicle 2: Generating All Tuples and Permutations. Addison-Wesley Publishing Company.
This is, in fact, the functional version of Knuth’s Algorithm G (2005).
9.
Korittky, Joachim. 1998. Functional METAPOST. Diplomarbeit, Universit¨at Bonn.
Conclusion and further reading
Stockmeyer, Paul K. 2005. The Tower of Hanoi: A bibliography. Available from http://www.cs.wm.edu/~pkstoc/biblio2. pdf.
We have come to the end of the Tour D’Hano¨ı. In the spirit of the wholemeal approach we started with an inductive definition of the
10
Purely Functional Lazy Non-deterministic Programming Sebastian Fischer
Oleg Kiselyov
Chung-chieh Shan
Christian-Albrechts University, Germany
[email protected]
FNMOC, CA, USA
[email protected]
Rutgers University, NJ, USA
[email protected]
Abstract
(Wadler 1985) or another MonadPlus instance, and sharing can be represented using a state monad (Acosta-G´omez 2007; §2.4.1). These features are particularly useful together. For instance, sharing the results of non-strict evaluation—known as call-by-need or lazy evaluation—ensures that each expression is evaluated at most once. This combination is so useful that it is often built-in: as delay in Scheme, lazy in OCaml, and memoization in Haskell. In fact, many programs need all three features. As we illustrate in §2, lazy functional logic programming (FLP) can be used to express search problems in the more intuitive generate-and-test style yet solve them using the more efficient test-and-generate strategy, which is to generate candidate solutions only to the extent demanded by the test predicate. This pattern applies to propertybased test-case generation (Christiansen and Fischer 2008; Fischer and Kuchen 2007; Runciman et al. 2008) as well as probabilistic inference (Goodman et al. 2008; Koller et al. 1997). Given the appeal of these applications, it is unfortunate that combining the three features naively leads to unexpected and undesired results, even crashes. For example, lazy in OCaml is not thread-safe (Nicollet et al. 2009), and its behavior is unspecified if the delayed computation raises an exception, let alone backtracks. Although sharing and non-determinism can be combined in Haskell by building a state monad that is a MonadPlus instance (Hinze 2000; Kiselyov et al. 2005), the usual monadic encoding of nondeterminism in Haskell loses non-strictness (see §2.2). The triple combination has also been challenging for theoreticians and practitioners of FLP (L´opez-Fraguas et al. 2007, 2008). After all, Algol has made us wary of combining non-strictness with any effect. The FLP community has developed a sound combination of laziness and non-determinism, call-time choice, embodied in the Curry language. Roughly, call-time choice makes lazy non-deterministic programs predictable and comprehensible because their declarative meanings can be described in terms of (and is often same as) the meanings of eager non-deterministic programs.
Functional logic programming and probabilistic programming have demonstrated the broad benefits of combining laziness (non-strict evaluation with sharing of the results) with non-determinism. Yet these benefits are seldom enjoyed in functional programming, because the existing features for non-strictness, sharing, and nondeterminism in functional languages are tricky to combine. We present a practical way to write purely functional lazy non-deterministic programs that are efficient and perspicuous. We achieve this goal by embedding the programs into existing languages (such as Haskell, SML, and OCaml) with high-quality implementations, by making choices lazily and representing data with non-deterministic components, by working with custom monadic data types and search strategies, and by providing equational laws for the programmer to reason about their code. Categories and Subject Descriptors D.1.1 [Programming Techniques]: Applicative (Functional) Programming; D.1.6 [Programming Techniques]: Logic Programming; F.3.3 [Logics and Meanings of Programs]: Studies of Program Constructs—Type structure General Terms Keywords
1.
Design, Languages
Monads, side effects, continuations, call-time choice
Introduction
Non-strict evaluation, sharing, and non-determinism are all valuable features in functional programming. Non-strict evaluation lets us express infinite data structures and their operations in a modular way (Hughes 1989). Sharing lets us represent graphs with cycles, such as circuits (surveyed by Acosta-G´omez 2007), and express memoization (Michie 1968), which underlies dynamic programming. Since Rabin and Scott’s Turing-award paper (1959), nondeterminism has been applied to model checking, testing (Claessen and Hughes 2000), probabilistic inference, and search. These features are each available in mainstream functional languages. A call-by-value language can typically model nonstrict evaluation with thunks and observe sharing using reference cells, physical identity comparison, or a generative feature such as Scheme’s gensym or SML’s exceptions. Non-determinism can be achieved using amb (McCarthy 1963), threads, or first-class continuations (Felleisen 1985; Haynes 1987). In a non-strict language like Haskell, non-determinism can be expressed using a list monad
1.1
Contributions
We embed lazy non-determinism with call-time choice into mainstream functional languages in a shallow way (Hudak 1996), rather than, say, building a Curry interpreter in Haskell (Tolmach and Antoy 2003). This new approach is especially practical because these languages already have mature implementations, because functional programmers are already knowledgeable about laziness, and because different search strategies can be specified as MonadPlus instances and plugged into our monad transformer. Furthermore, we provide equational laws that programmers can use to reason about their code, in contrast to previous accounts of call-time choice based on directed, non-deterministic rewriting. The key novelty of our work is that non-strictness, sharing, and non-determinism have not been combined in such a general way before in purely functional programming. Non-strictness and non-determinism can be combined using data types with nondeterministic components, such that a top-level constructor can be
Permission to make digital or hard copies of all or part of this work for personal or classroom use is granted without fee provided that copies are not made or distributed for profit or commercial advantage and that copies bear this notice and the full citation on the first page. To copy otherwise, to republish, to post on servers or to redistribute to lists, requires prior specific permission and/or a fee. ICFP’09, August 31–September 2, 2009, Edinburgh, Scotland, UK. c 2009 ACM 978-1-60558-332-7/09/08. . . $5.00 Copyright
11
the duplication of a computation bound to x does not cause it to be evaluated twice. In a lazy language, the value of the expression factorial 100 would only be computed once when evaluating iterate (‘div‘2) (factorial 100). This property—called sharing—makes lazy evaluation strictly more efficient than eager evaluation, at least on some problems (Bird et al. 1997).
computed without fixing its arguments. However, such an encoding defeats Haskell’s built-in sharing mechanism, because a piece of non-deterministic data that is bound to a variable that occurs multiple times may evaluate to a different (deterministic) value at each occurrence. We retain sharing by annotating programs explicitly with a monadic combinator for sharing. We provide a generic library to define non-deterministic data structures that can be used in non-strict, non-deterministic computations with explicit sharing. Our library is implemented as a monad transformer and can, hence, be combined with arbitrary monads for non-determinism. We are, thus, not restricted to the list monad (which implements depth-first search) but can also use monads that backtrack more efficiently or provide a complete search strategy. The library does not directly support logic variables—perhaps the most conspicuous feature of FLP—and the associated solution techniques of narrowing and residuation, but logic variables can be emulated using non-deterministic generators (Antoy and Hanus 2006) or managed using an underlying monad of equality and other constraints. We present our concrete code in Haskell, but we have also implemented our approach in OCaml. Our monadic computations perform competitively against corresponding computations in Curry that use non-determinism, narrowing, and unification. 1.2
2.2
return :: a -> m a (>>=) :: m a -> (a -> m b) -> m b
Structure of the paper
The operation return builds a deterministic computation that yields a value of type a, and the operation >>= (“bind”) chains computations together. Haskell’s do-notation is syntactic sugar for long chains of >>=. For example, the expression
In §2 we describe non-strictness, sharing, and non-determinism and why they are useful together. We also show that their naive combination is problematic, to motivate the explicit sharing of non-deterministic computations. In §3 we clarify the intuitions of sharing and introduce equational laws to reason about lazy non-determinism. Section 4 develops an easy-to-understand implementation in several steps. Section 5 generalizes and speeds up the simple implementation. We review the related work in §6 and then conclude.
2.
do x >= \x -> e2. If a monad m is an instance of MonadPlus, then two additional operations are available: mzero :: m a mplus :: m a -> m a -> m a
Non-strictness, sharing, and non-determinism
In this section, we describe non-strictness, sharing, and non-determinism and explain why combining them is useful and non-trivial. 2.1
Non-determinism
Programming non-deterministically can simplify the declarative formulation of an algorithm. For example, many languages are easier to describe using non-deterministic rather than deterministic automata. As logic programming languages such as Prolog and Curry have shown, the expressive power of non-determinism simplifies programs because different non-deterministic results can be viewed individually rather than as members of a (multi-)set of possible results (Antoy and Hanus 2002). In Haskell, we can express non-deterministic computations using lists (Wadler 1985) or, more generally, monads that are instances of the type class MonadPlus. A monad m is a type constructor that provides two polymorphic operations:
Here, mzero is the primitive failing computation, and mplus chooses non-deterministically between two computations. For the list monad, return builds a singleton list, mzero is an empty list, and mplus is list concatenation. As an example, the following monadic operation computes all permutations of a given list non-deterministically:
Lazy evaluation
Lazy evaluation is illustrated by the following Haskell predicate, which checks whether a given list of numbers is sorted: isSorted :: [Int] -> Bool isSorted (x:y:zs) = (x [a] -> m [a] perm [] = return [] perm (x:xs) = do ys [a] -> m [a] insert x xs = return (x:xs) ‘mplus‘ case xs of [] -> mzero (y:ys) -> do zs a) -> a -> [a] iterate next x = x : iterate next (next x)
The operation perm permutes a list by recursively inserting elements at arbitrary positions. To insert an element x at an arbitrary position in xs, the operation insert either puts x in front of xs or recursively inserts x somewhere in the tail of xs if xs is not empty. Non-determinism is especially useful when formulating search algorithms. Following the generate-and-test pattern, we can find solutions to a search problem by non-deterministically describing candidate solutions and using a separate predicate to filter results. It is much easier for a programmer to express generation and testing separately than to write an efficient search procedure by hand, because the generator can follow the structure of the data
The test isSorted (iterate (‘div‘2) n) yields the result False if n>0. It does not terminate if n m a -> m (List m a) -> m (List m a) insert mx mxs = cons mx mxs ‘mplus‘ do Cons my mys [Int] -> m [Int] sort xs = do ys m (List m Int) -> m (List m Int) sort xs = let ys = perm xs in do True m (m a)
nil :: Monad m => m (List m a) nil = return Nil
where m is an instance of MonadPlus that supports explicit sharing. (We describe the implementation of explicit sharing in §§4–5.) The function sort can then be redefined to actually sort:
cons :: Monad m => m a -> m (List m a) -> m (List m a) cons x y = return (Cons x y)
sort xs = do ys m Bool isSorted ml = ml >>= \l -> case l of Cons mx mxs -> mxs >>= \xs -> case xs of Cons my mys -> mx >>= \x -> my >>= \y -> if x return True _ -> return True
In this version of sort, the variable ys denotes the same permutation wherever it occurs but is nevertheless only computed as much as demanded by the predicate isSorted.
3.
Programming with lazy non-determinism
In this section we formalize the share combinator and specify equational laws with which a programmer can reason about nondeterministic programs with share and predict their observations. Before the laws, we first present a series of small examples to clarify how to use share and what share does.
By generating lists with non-deterministic arguments, we can define a lazier version of the permutation algorithm.
3.1
perm :: MonadPlus m => m (List m a) -> m (List m a) perm ml = ml >>= \l -> case l of Nil -> nil Cons mx mxs -> insert mx (perm mxs)
The intuition of sharing
We define two simple programs. The computation coin flips a coin and non-deterministically returns either 0 or 1. coin :: MonadPlus m => m Int coin = return 0 ‘mplus‘ return 1
Note that we no longer evaluate (bind) the recursive call of perm in order to pass the result to the operation insert, because insert now takes a non-deterministic list as its second argument.
1 In
13
fact, the signature has additional class constraints; see §5.
The function duplicate evaluates a given computation a twice.
first :: MonadPlus m => m (List m a) -> m a first l = l >>= \(Cons x xs) -> x
duplicate :: Monad m => m a -> m (a, a) duplicate a = do u m (List m a) dupl x = cons x (cons x nil) The function dupl is subtly different from duplicate: whereas duplicate runs a computation twice and returns a data structure with the results, dupl returns a data structure containing the same computation twice without running it. The following two examples illustrate the benefit of data structures with non-deterministic components.
Sharing enforces call-time choice
We contrast three ways to bind x: dup_coin_let dup_coin_bind
= let x = coin in duplicate x = do x >= \x -> return (return x) This implementation satisfies the (Choice) law, but it does not satisfy the (Fail) and (Bot) laws. The (Lzero) law of MonadPlus shows that this implementation renders share mzero equal to mzero, which is observationally different from the return mzero required by (Fail). This attempt ensures early choice using early demand, so we get eager sharing, rather than lazy sharing as desired. 4.2
Memoization
We can combine late demand and early choice using memoization. The idea is to delay the choice until it is demanded, and to remember the choice when it is made for the first time so as to not make it again if it is demanded again. To demonstrate the idea, we define a very specific version of share that fixes the monad and the type of shared values. We use a state monad to remember shared monadic values. A state monad is an instance of the following type class, which defines operations to query and update a threaded state component. class MonadState s m where get :: m s put :: s -> m ()
x ? y -> y
Using the theorem above, we conclude that JC[a?b]K = JC[a]?C[b]K, which inspired our (Choice) law.
data Thunk a = Uneval (Memo a) | Eval a Here, Memo is the name of our monad. It threads a list of Thunks through non-deterministic computations represented as lists.
3 without
selective strictness via seq 4 The set monad can be implemented in Haskell just like the list monad, with the usual Monad and MonadPlus instances that do not depend on Eq or Ord, as long as computations can only be observed using the null predicate.
newtype Memo a = Memo { unMemo :: [Thunk Int] -> [(a, [Thunk Int])] }
16
The instance declarations for the type classes Monad, MonadState, and MonadPlus are as follows:
We also reuse the memo function, which has now a different type. We could try to define share simply as a renaming for memo again:
instance Monad Memo where return x = Memo (\ts -> [(x,ts)]) m >>= f = Memo (concatMap (\(x,ts) -> unMemo (f x) ts) . unMemo m)
share :: Memo (List Memo Int) -> Memo (Memo (List Memo Int)) share a = memo a However, with this definition lists are not shared deeply. This behavior corresponds to the expression heads_bind where the head and the tail of the demanded list are still executed whenever they are demanded and may hence yield different results when duplicated. This implementation does not satisfy the (HNF) law. We can remedy this situation by recursively memoizing the head and the tail of a shared list:
instance MonadState [Thunk Int] Memo where get = Memo (\ts -> [(ts,ts)]) put ts = Memo (\_ -> [((),ts)]) instance MonadPlus Memo where mzero = Memo (const []) a ‘mplus‘ b = Memo (\ts -> unMemo a ts ++ unMemo b ts)
share :: Memo (List Memo Int) -> Memo (Memo (List Memo Int)) share a = memo (do l nil Cons x xs -> do y = eval return (Cons (return y) (return ys))
This implementation of share adds an unevaluated thunk to the current store and returns a monadic action that, when executed, queries the store and either returns the already evaluated result or evaluates the unevaluated thunk before updating the threaded state. The argument a given to share is not demanded until the inner action is performed. Hence, this implementation of share satisfies the (Fail) and (Bot) laws. Furthermore, the argument is only evaluated once, followed by an update of the state to remember the computed value. Hence, this implementation of share satisfies the (Choice) law (up to observation, as defined in §4.4). If the inner action is duplicated and evaluated more than once, then subsequent calls will yield the same result as the first call due to memoization. 4.3
Observing non-deterministic results
In order to observe the results of a computation that contains nondeterministic components, we need a function (such as run in Figure 2) that evaluates all the components and combines the resulting alternatives to compute a non-deterministic choice of deterministic results. For example, we can define a function eval that computes all results from a non-deterministic list of numbers.
The lists returned by eval are fully determined. Using eval, we can define an operation run that computes the results of a nondeterministic computation: run :: Memo (List Memo Int) -> [List Memo Int] run m = map fst (unMemo (m >>= eval) []) In order to guarantee that the observed results correspond to predicted results according to the laws in §3.2, we place two requirements on the monad used to observe the computation ([] above). (In contrast, the laws in §3.2 constrain the monad used to express the computation (Memo above).) Idempotence of mplus The (Choice) law predicts that the computation run (share coin = λ . ret Nil) gives ret0 Nil ⊕0 ret0 Nil. However, our implementation gives a single solution ret0 Nil (following the (Ignore) law, as it turns out). Hence, we require ⊕0 to be idempotent; that is, m ⊕0 m = m. This requirement is satisfied if we abstract from the multiplicity of results (considering [] as the set monad rather than the list
Non-deterministic components
The version of share just developed memoizes only integers. However, we want to memoize data with non-deterministic components, such as permuted lists that are computed on demand. So instead of thunks that evaluate to numbers, we redefine the Memo monad to store thunks that evaluate to lists of numbers now. newtype Memo a = Memo { unMemo :: [Thunk (List Memo Int)] -> [(a, [Thunk (List Memo Int)])] }
5 This
implementation of share does not actually type-check because share x in the body needs to invoke the previous version of share, for the type Int, rather than this version, for the type List Memo Int. The two versions can be made to coexist, each maintaining its own state, but we develop a polymorphic share combinator in §5 below, so the issue is moot.
The instance declarations for Monad and MonadPlus stay the same. In the MonadState instance only the state type needs to be adapted.
17
monad), as is common practice in FLP, or if we treat ⊕0 as averaging the weights of results, as is useful for probabilistic inference. Distributivity of bind over mplus the result of the computation
For example, we have applied our approach to express and sample from probability distributions as OCaml programs in direct style (Filinski 1999). With less development effort than state-of-theart systems, we achieved comparable concision and performance (Kiselyov and Shan 2009). The implementation of our monad transformer is available as a Hackage package at: http://hackage.haskell.org/cgi-bin/ hackage-scripts/package/explicit-sharing-0.1
According to the (Choice) law,
run (share coin = λ c. coin = λ y. c = λ x. ret (Cons (ret x) (ret (Cons (ret y) (ret Nil))))) is the following non-deterministic choice of lists (we write hx, yi to denote ret0 (Cons (ret0 x) (ret0 (Cons (ret0 y) (ret0 Nil))))). 0
0
5.1
0
We have seen in the previous section that in order to share nested, non-deterministic data deeply, we need to traverse it and apply the combinator share recursively to every non-deterministic component. We have implemented deep sharing for the type of nondeterministic lists, but want to generalize this implementation to support arbitrary user-defined types with non-deterministic components. It turns out that the following interface to non-deterministic data is sufficient:
(h0, 0i ⊕ h0, 1i) ⊕ (h1, 0i ⊕ h1, 1i) However, our implementation yields (h0, 0i ⊕0 h1, 0i) ⊕0 (h0, 1i ⊕0 h1, 1i). In order to equate these two trees, we require the following distributive law between =0 and ⊕0 . a =0 λ x. ( f x ⊕0 g x) = (a =0 f ) ⊕0 (a =0 g)
class MonadPlus m => Nondet m a where mapNondet :: (forall b . Nondet m b => m b -> m (m b)) -> a -> m a
If the observation monad satisfies this law, then the two expressions above are equal (we write coin0 to denote ret0 0 ⊕0 ret0 1): (h0, 0i ⊕0 h0, 1i) ⊕0 (h1, 0i ⊕0 h1, 1i) = (coin0 =0 λ y. h0, yi) ⊕0 (coin0 =0 λ y. h1, yi)
A non-deterministic type a with non-deterministic components wrapped in the monad m can be made an instance of Nondet m by implementing the function mapNondet, which applies a monadic transformation to each non-deterministic component. The type of mapNondet is a rank-2 type: the first argument is a polymorphic function that can be applied to non-deterministic data of any type. We can make the type List m Int, of non-deterministic number lists, an instance of Nondet as follows.
= coin0 =0 λ y. (h0, yi ⊕0 h1, yi) = (h0, 0i ⊕0 h1, 0i) ⊕0 (h0, 1i ⊕0 h1, 1i). Hence, the intuition behind distributivity is that the observation monad does not care about the order in which choices are made. This intuition captures the essence of implementing call-time choice: we can perform choices on demand and the results are as if we performed them eagerly. In general, it is fine to use our approach with an observation monad that does not match our requirements, as long as we are willing to abstract from the mismatch. For example, the list monad satisfies neither idempotence nor distributivity, yet our equational laws are useful in combination with the list monad if we abstract from the order and multiplicities of results. We also do not require that ⊕0 be associative or that 0/ 0 be a left or right unit of ⊕0 .
5.
Non-deterministic data
instance MonadPlus m => Nondet m Int where mapNondet _ c = return c instance Nondet m a => Nondet m (List m a) where mapNondet _ Nil = return Nil mapNondet f (Cons x xs) = do y m a eval = mapNondet (\a -> a>>=eval>>=return.return)
We achieve the first goal by introducing a type class with the interface to process non-deterministic data. We achieve the second goal by defining a monad transformer Lazy that adds sharing to any instance of MonadPlus. After describing a straightforward implementation of this monad transformer, we show how to implement it differently in order to improve performance significantly. Both of these generalizations are motivated by practical applications in non-deterministic programming.
This operation generalizes the specific version for lists given in §4.4. In order to determine a value, we determine values for the arguments and combine the results. The bind operation of the monad nicely takes care of the combination. Our original motivation for abstracting over the interface of non-deterministic data was to define the operation share with a more general type. In order to generalize the type of share to allow not only different types of shared values but also different monad type constructors, we define another type class.
1. The ability to work with user-defined types makes it easier to compose deterministic and non-deterministic code and to draw on the sophisticated type and module systems of existing functional languages. 2. The ability to plug in different underlying monads makes it possible to express techniques such as breadth-first search (Spivey 2000), heuristics, constraint solving (Nordin and Tolmach 2001), and weighted results.
class MonadPlus m => Sharing m where share :: Nondet m a => m a -> m (m a) Non-determinism monads that support the operation share are instances of this class. We next define an instance of Sharing with the implementation of share for arbitrary non-deterministic types.
18
5.2
State monad transformer
This completes an implementation of our monad transformer for lazy non-determinism, with all of the functionality motivated in §§2–3.
The implementation of memoization in §4 uses a state monad to thread a list of thunks through non-deterministic computations. The straightforward generalization is to use a state monad transformer to thread thunks through computations in arbitrary monads. A state monad transformer adds the operations defined by the type class MonadState to an arbitrary base monad. The type for Thunks generalizes easily to an arbitrary monad:
5.3
data Thunk m a = Uneval (m a) | Eval a Instead of using a list of thunks, we use a ThunkStore with the following interface. Note that the operations lookupThunk and insertThunk deal with thunks of arbitrary type. emptyThunks :: ThunkStore getFreshKey :: MonadState ThunkStore lookupThunk :: MonadState ThunkStore => Int -> m (Thunk m a) insertThunk :: MonadState ThunkStore => Int -> Thunk m a -> m
Optimizing performance
We have applied some optimizations that improve the performance of our implementation significantly. We use the permutation sort in §2 for a rough measure of performance. The implementation just presented exhausts the search space for sorting a list of length 20 in about 5 minutes.6 The optimizations described below reduce the run time to 7.5 seconds. All implementations run permutation sort in constant space (5 MB or less) and the final implementation executes permutation sort on a list of length 20 roughly three times faster than the fastest available compiler for Curry, the M¨unster Curry Compiler (MCC). As detailed below, we achieve this competitive performance by
m => m Int m
1. reducing the amount of pattern matching in invocations of the monadic bind operation, 2. reducing the number of store operations when storing shared results, and 3. manually inlining and optimizing library code.
m ()
There are different options to implement this interface. We have implemented thunk stores using the generic programming features provided by the Data.Typeable and Data.Dynamic modules but omit corresponding class contexts for the sake of clarity. Lazy monadic computations can now be performed in a monad that threads a ThunkStore. We obtain such a monad by applying the StateT monad transformer to an arbitrary instance of MonadPlus.
5.3.1
Less pattern matching
The Monad instance for the StateT monad transformer performs pattern matching in every call to >>= in order to thread the store through the computation. This is wasteful especially during computations that do not access the store because they do not perform explicit sharing. We can avoid this pattern matching by using a different instance of MonadState. We define the continuation monad transformer ContT:7
type Lazy m = StateT ThunkStore m For any instance m of MonadPlus, the type constructor Lazy m is an instance of Monad, MonadPlus, and MonadState ThunkStore. We only need to define the instance of Sharing ourselves, which implements the operation share.
newtype ContT m a = C { unC :: forall w . (a -> m w) -> m w } runContT :: Monad m => ContT m a -> m a runContT m = unC m return
instance MonadPlus m => Sharing (Lazy m) where share a = memo (a >>= mapNondet share)
We can make ContT m an instance of the type class Monad without using operations from the underlying monad m:
The implementation of share uses the operation memo to memoize the argument and the operation mapNondet to apply share recursively to the non-deterministic components of the given value. The function memo resembles the specific version given in §4.2 but has a more general type.
instance Monad (ContT m) where return x = C (\c -> c x) m >>= k = C (\c -> unC m (\x -> unC (k x) c)) An instance for MonadPlus can be easily defined using the corresponding operations of the underlying monad. The interesting exercise is to define an instance of MonadState using ContT. When using continuations, a reader monad—a monad where actions are functions that take an environment as input but do not yield one as output—can be used to pass state. More specifically, we need the following operations of reader monads:
memo :: MonadState ThunkStore m => m a -> m (m a) memo a = do key do x m s local :: MonadReader s m => (s -> s) -> m a -> m a The function ask queries the current environment, and the function local executes a monadic action in a modified environment. In combination with ContT, the function local is enough to implement state updates:
The only difference in this implementation of memo from before is that it uses more efficient thunk stores instead of lists of thunks. In order to observe a lazy non-deterministic computation, we use the functions eval to compute fully determined values and evalStateT to execute actions in the transformed state monad.
instance Monad m => MonadState s (ContT (ReaderT s m)) where get = C (\c -> ask >>= c) put s = C (\c -> local (const s) (c ()))
run :: Nondet (Lazy m) a => Lazy m a -> m a run a = evalStateT (a >>= eval) emptyThunks
6 We
performed our experiments on an Apple MacBook with a 2.2 GHz Intel Core 2 Duo processor using GHC with optimizations (-O2). 7 This implementation differs from the definition shipped with GHC in that the result type w for continuations is higher-rank polymorphic.
This function is the generalization of the run function to arbitrary data types with non-deterministic components that are expressed in an arbitrary instance of MonadPlus.
19
With these definitions, we can define our monad transformer Lazy:
reverse function on long lists, which involves a lot of deterministic pattern-matching. In this benchmark, the monadic code is roughly 20% faster than the corresponding Curry code in MCC. The overhead compared to a non-monadic Haskell program is about the same order of magnitude. Our library does not directly support narrowing and unification of logic variables but can emulate it by means of lazy nondeterminism. We have measured the overhead of such emulation using a functional logic implementation of the last function:
type Lazy m = ContT (ReaderT ThunkStore m) We can reuse from §5.2 the definition of the Sharing instance and of the memo function used to define share. After this optimization, searching all sorted permutations of a list of length 20 takes about 2 minutes rather than 5. 5.3.2
Fewer state manipulations
The function memo just defined performs two state updates for each shared value that is demanded: one to insert the unevaluated shared computation and one to insert the evaluated result. We can save half of these manipulations by inserting only evaluated head-normal forms and using lexical scope to access unevaluated computations. We use a different interface to stores now, again abstracting away the details of how to implement this interface in a type-safe manner.
last l | l =:= xs ++ [x] = x where x,xs free This Curry function uses narrowing to bind xs to the spine of the init of l and unification to bind x and the elements of xs to the elements of l. We can translate it to Haskell by replacing x and xs with non-deterministic generators and implementing the unification operator =:= as equality check. When applying last to a list of determined values, the monadic Haskell code is about six times faster than the Curry version in MCC. The advantage of unification shows up when last is applied to a list of logic variables: in Curry, =:= can unify two logic variables deterministically, while an equality check on non-deterministic generators is non-deterministic and leads to search-space explosion. More efficient unification could be implemented using an underlying monad of equality constraints. All programs used for benchmarking are available under: http: //github.com/sebfisch/explicit-sharing/tree/0.1.1
emptyStore :: Store getFreshKey :: MonadState Store m => m Int lookupHNF :: MonadState Store m => Int -> m (Maybe a) insertHNF :: MonadState Store m => Int -> a -> m () Based on this interface, we can define a variant of memo that only stores evaluated head normal forms. memo :: MonadState Store m => m a -> m (m a) memo a = do key do x > >
∗∗∗ sfl
loop sfs
sfr
sff
sfr
Figure 1. The Sequential ( ≫ ) and Parallel ( ∗∗∗ ) Composition Combinators
Figure 2. The Feedback Combinator (loop) 3.3.2 Switches
Note that Set is the “type of types” in Agda (similar to kind ∗ in Haskell)2 . Next we introduce signal vector descriptors. A signal vector descriptor is simply a (type level) list of signal descriptors:
Signal function networks are made dynamic through the use of switches. Basic switches have the following type: switch : ∀ {as bs } → {e : Set } → SF as (E e :: bs) → (e → SF as bs) → SF as bs
SVDesc : Set SVDesc = List SigDesc
dswitch : ∀ {as bs } → {e : Set } → SF as (E e :: bs) → (e → SF as bs) → SF as bs
For the purpose of stating the new conceptual definition of signal functions, and for use in semantic definitions later, we postulate a function (SVRep) that maps a signal vector descriptor to some suitable type for representing a sample of signal vectors of that description, and use this to define signal vectors:
(Agda allows the type of an implicit argument to be omitted when it is clear from the context. In the definitions above, both as and bs are clearly of type SVDesc as they are used as arguments to the type constructor SF .) The behaviour of a switch is to run the subordinate signal function (the first explicit argument), emitting all but the head (the event) of the output vector as the overall output. When there is an event occurrence in the event signal, the value of that signal is fed into the function (the second explicit argument) to generate a residual signal function. The entire switch is then removed from the network and replaced with this residual signal function. The difference between a switch and a dswitch (decoupled switch) is whether, at the moment of switching, the overall output is the output from the residual signal function (switch), or the output from the subordinate signal function (dswitch).3 A key point regarding switches is that the residual signal function does not start “running” until it is applied to the input signal at the moment of switching. Consequently, rather than having a single global Time, each signal function has its own local time.
SVRep : SVDesc → Set SigVec : SVDesc → Set SigVec as ≈ Time → SVRep as
However, we do not require the existence of such a function: an implementation may opt to not represent signal vectors explicitly at all. Finally, we refine the conceptual definition of signal functions: SF : SVDesc → SVDesc → Set SF as bs ≈ SigVec as → SigVec bs
3.3 Example Combinators and Primitives To demonstrate the new conceptual model, we define some common primitive signal functions and combinators from Yampa. These primitives either operate at the reactive level, or mediate between the functional and reactive levels.
Local Time. The time since this signal function was applied to its input signal. This will have been either when the entire system started, or when the sub-network containing the signal function in question was switched in.
3.3.1 Sequential and Parallel Composition
3.3.3 Loops
Signal functions can be composed sequentially (≫) or in parallel (∗∗∗) (see Figure 1):
The loop primitive provides the means for introducing feedback loops into signal function networks. A loop consists of two signal functions: a subordinate signal function (the first explicit argument) and a feedback signal function (the second explicit argument). The input of the feedback signal function is a suffix of the output of the subordinate signal function, and the output of the feedback signal function is a suffix of the input to the subordinate signal function:
≫ : {as bs cs : SVDesc } → SF as bs → SF bs cs → SF as cs ∗∗∗
: {as bs cs ds : SVDesc } → SF as cs → SF bs ds → SF (as + + bs) (cs + + ds)
(In Agda, is used to indicate the argument positions for infix and mixfix operators, while the curly braces are used to enclose implicit arguments: arguments that only have to be provided at an application site if they cannot be inferred from the context.) Note that ∗∗∗ composes two signal functions that take different inputs. For parallel composition where both signal functions take the same input, there is the & & & combinator:
loop : ∀ {as bs cs ds } → SF (as + + cs) (bs + + ds) → SF ds cs → SF as bs
Intuitively, we use the feedback signal function to connect some of the output signals of the subordinate signal function to some of its input signals, forming a feedback loop (see Figure 2).
& & & : {as bs cs : SVDesc } → SF as bs → SF as cs → SF as (bs + + cs)
3.3.4 Primitive Signal Functions We can lift pure functions to the reactive level using the primitives pure and pureE 4 . Such lifted signal functions are always stateless:
2 Strictly
speaking, SigDesc should have type Set1 (the type of Set). However, for clarity, we use the Agda option that accepts Set as the type of Set. We have successfully implemented the type system without this option, but, because Agda does not support universe polymorphism, the result is very repetitive code and loss of conceptual clarity.
3 In Yampa, dswitch also decouples part of its input from part of its output, but we do not assume any such behaviour here. 4 It is possible to have one pure primitive that is overloaded to operate on either time domain, but we do not do so here for clarity.
26
pure
: {a b : Set } → (a → b) → SF [C a ] [C b ]
Its purpose is to monitor a real-valued continuous-time input signal and output the same signal until the input dips below 0. At this point, the output should be clamped to 0, and then remain at 0 from then on.
pureE : {a b : Set } → (a → b) → SF [E a ] [E b ]
Note that we are using [s ] as a synonym for (s :: [ ]). We can lift values to the reactive level using the primitive constant . This creates a signal function with a constant, continuoustime, output:
clamp : SF [C R] [C R] clamp = switch ((pure (λ x → x < 0 ) ≫ edge) & & & pure id) (λ → constant 0 )
constant : ∀ {as } → {b : Set } → b → SF as [C b ]
Events can only be generated and accessed by event processing primitives. Examples include
4. Decoupled Signal Functions As previously discussed, the loop combinator allows feedback to be introduced into a network. This is an essential capability, as feedback is widely used in reactive programming. However, feedback must not cause deadlock due to a signal function depending on its own output in an unproductive manner. To guarantee this, we conservatively prohibit instantaneous cycles in the network. This is a common design choice in reactive languages, but our way of enforcing it is different. We identify decoupled signal functions, essentially a class of signal functions that can be used safely in feedback loops, and index the type of a signal function by whether or not it is decoupled.
• edge, which produces an event whenever the boolean input
signal changes from false to true; • hold , which emits as a continuous-time signal the value carried
by its most recent input event; • never , which outputs an event signal containing no event oc-
currences; • now , which immediately outputs one event, but never does so
again. edge : SF [C Bool ] [E Unit ]
Decoupled Signal Function. A signal function is decoupled if, at any given time, its output can depend upon its past inputs, but not its present and future inputs:
hold : {a : Set } → a → SF [E a ] [C a ] never : ∀ {as } → {b : Set } → SF as [E b ] now : ∀ {as } → SF as [E Unit ]
SF dec as bs = { sf : SF as bs | ∀ (t : Time) (sv 1 sv 2 : SigVec as) . (∀ t < t. sv 1 t ≡ sv 2 t ) ⇒ (sf sv 1 t ≡ sf sv 2 t)}
The primitive pre conceptually introduces an infinitesimal delay: pre : ∀ {a } → SF [C a ] [C a ]
To make this precise, the ideal semantics of pre is that it outputs whatever its input was immediately prior to the current time; that is, the left limit of the input signal at all points:
Decoupled Cycle. A cycle is decoupled if it passes through a decoupled signal function.
∀ (t : Time + ) (s : Signal a) . pre s t = lim− s t t →t
Instantaneous Cycle (Algebraic Loop). A cycle is instantaneous if it does not pass through a decoupled signal function.
+
Here, Time denotes positive time. Consequently, at any given point, the output of pre does not depend upon its present input, which is the crucial property of pre: see Section 4. The primitive pre is usually implemented as a delay of one time step. Of course, this only approximates the ideal semantics. However, if the length of the time steps tends to zero, the semantics of such an implementation of pre converges to the ideal semantics. Note that pre is only defined for continuous-time signals. This is because the left limit at any point of a discrete-time signal (a signal defined only at countably many points in time) is undefined. In our setting, this amounts to an event signal without any occurrences; which is a signal equivalent to the output from never . Applying pre to an event signal would thus be pointless (use never instead), and any attempt to do so would likely be a mistake stemming from a misunderstanding of the semantics of pre. Disallowing pre on events thus eliminates a potential source of programming bugs. In contrast, Yampa, because discrete-time signals are realised as continuous-time signals carrying an option type (see Section 3.1), cannot rule out pre being applied to event signals, nor can it guarantee the proper semantics of such an application. Note also that pre is only defined for positive time. When the local time is zero (henceforth referred to as time 0 ), the output of pre is necessarily undefined as there are no prior points in time. Thus we need an initialise combinator that defines a signal function’s output at time 0 :
In Yampa, the onus is on the programmer to ensure that all cycles are correctly decoupled. An instantaneous cycle will not be detected statically, and the program could well loop at run-time. Many reactive languages deal with this problem by requiring a specific decoupling construct (a language primitive) to appear syntactically within the definition of any feedback loops. This works in a first order setting, but becomes very restrictive in a higher order setting as decoupled signal functions cannot be taken as parameters and used to decouple loops. Our solution is to encode decoupledness information in the types of signal functions. This allows us to statically ensure that a well-typed program does not contain any instantaneous cycles. Furthermore, the decoupledness of a signal function will be visible in its type signature, providing guidance to an FRP programmer. 4.1 Decoupledness Descriptors We introduce a data type of decoupledness descriptors: data Dec : Set where dec : Dec -- decoupled signal functions cau : Dec -- causal signal functions
We then index SF with a decoupledness descriptor: SF : SVDesc → SVDesc → Dec → Set
We can now enforce that the feedback signal function within a loop is decoupled:
initialise : ∀ {as b } → b → SF as [C b ] → SF as [C b ]
loop : ∀ {as bs cs ds } → {d : Dec } → SF (as + + cs) (bs + + ds) d → SF ds cs dec → SF as bs d
Initialisation is discussed further in Section 5. 3.4 Example
The primitive signal functions now need to be retyped to include appropriate decoupledness descriptors:
Let us illustrate the concepts and definitions that have been introduced thus far by constructing a simple signal function network.
27
pure : ∀ {a b } → (a → b) → SF [C a ] [C b ] cau
4.2.1 Recurring Switches
pureE : ∀ {a b } → (a → b) → SF [E a ] [E b ] cau
For this example we need to introduce an additional class of switching combinators: recurring switches (similar to every in Lucid Synchrone). The behaviour of a recurring switch is to apply its subordinate signal function to the tail of its input, producing the overall output. Whenever an event (the head of the input) occurs, the signal function carried by that event replaces the subordinate signal function. Recurring switches come in two varieties: like basic switches, they differ in whether the output at the instant of switching is from the new (rswitch) or old (drswitch) subordinate signal function.
constant : ∀ {as b } → b → SF as [C b ] dec edge : SF [C Bool ] [E Unit ] cau hold : ∀ {a } → a → SF [E a ] [C a ] cau never : ∀ {as b } → SF as [E b ] dec now : ∀ {as } → SF as [E Unit ] dec pre : ∀ {a } → SF [C a ] [C a ] dec initialise : ∀ {as b } → {d : Dec } → b → SF as [C b ] d → SF as [C b ] d
rswitch : ∀ {as bs d1 d2 } → SF as bs d1 → SF (E (SF as bs d2 ) :: as) bs cau
Notice that, from the definition of decoupled signal functions, it is evident that they are a subtype of causal signal functions (dec >> first f >>> arr swap where swap (a, b) = (b, a)
2. An Introduction to Arrows Arrows [23] are a generalization of monads that relax the stringent linearity imposed by monads, while retaining a disciplined style of composition. Arrows have enjoyed a wide range of applications, often as a domain-specific embedded language (DSEL [19, 20]), including the many Yampa applications cited earlier, as well as parsers and printers [25], parallel computing [18], and so on. Arrows also have a theoretical foundation in category theory, where they are strongly related to (but not precisely the same as) Freyd categories [2, 37].
Parallel composition can be defined as a sequence of first and second: (***) :: (Arrow a) ⇒ a b c → a b’ c’ → a (b, b’) (c, c’) f *** g = first f >>> second g
A mere implementation of the arrow combinators, of course, does not make it an arrow – the implementation must additionally satisfy a set of arrow laws, which are shown in Figure 2.
2.1 Conventional Arrows
2.2 Looping Arrows
Like monads, arrows capture a certain class of abstract computations, and offer a way to structure programs. In Haskell this is achieved through the Arrow type class:
To model recursion, we can introduce a loop combinator [32]. The exponential example given in the introduction requires recursion, as do many applications in signal processing, for example. In Haskell this combinator is captured in the ArrowLoop class:
class Arrow a where arr :: (b → c) → a b c (>>>) :: a b c → a c d → a b d first :: a b c → a (b,d) (c,d)
class Arrow a ⇒ ArrowLoop a where loop :: a (b,d) (c,d) → a b c
A valid instance of this class should satisfy the additional laws shown in Figure 3. This class and its associated laws are related to the trace operator in [40, 17], which was generalized to arrows in [32]. We find that arrows are best viewed pictorially, especially for applications such as signal processing, where domain experts commonly draw signal flow diagrams. Figure 1 shows some of the basic combinators in this manner, including loop.
The combinator arr lifts a function from b to c to a “pure” arrow computation from b to c, namely a b c where a is the arrow type. The output of a pure arrow entirely depends on the input (it is analogous to return in the Monad class). >>> composes two arrow computations by connecting the output of the first to the input of the second (and is analogous to bind ((>>=)) in the Monad class). But in addition to composing arrows linearly, it is desirable to compose them in parallel – i.e. to allow “branching” and “merging” of inputs and outputs. There are several ways to do this, but by simply defining the first combinator in the Arrow class, all other combinators can be defined. first converts an arrow computation taking one input and one result, into an arrow computation taking two inputs and two results. The original arrow is applied to the first part of the input, and the result becomes the first part of the output. The second part of the input is fed directly to the second part of the output. Other combinators can be defined using these three primitives. For example, the dual of first can be defined as:
2.3 Arrow Syntax Recall the Yampa definition of the exponential function given earlier: exp = proc () → do rec let e = 1 + i i ← integral −≺ e returnA −≺ e
This program is written using arrow syntax, introduced by Paterson [32] and adopted by GHC (the predominant Haskell implementation) because it ameliorates the cumbersome nature of writing in
36
left identity right identity associativity composition extension functor exchange unit association
arr id ≫ f f ≫ arr id (f ≫ g) ≫ h arr (g · f ) first (arr f ) first (f ≫ g) first f ≫ arr (id × g) first f ≫ arr fst first (first f ) ≫ arr assoc where assoc ((a, b), c)
= = = = = = = = = =
f f f ≫ (g ≫ h) arr f ≫ arr g arr (f × id) first f ≫ first g arr (id × g) ≫ first f arr fst ≫ f arr assoc ≫ first f (a, (b, c))
Figure 2. Conventional Arrow Laws left tightening right tightening sliding vanishing superposing extension
loop (first h ≫ f ) loop (f ≫ first h) loop (f ≫ arr (id × k)) loop (loop f ) second (loop f ) loop (arr f ) where trace f b
= = = = = = =
h ≫ loop f loop f ≫ h loop (arr (id × k) ≫ f ) loop (arr assoc −1 ≫ f ≫ arr assoc) loop (arr assoc ≫ second f ≫ arr assoc −1 ) arr (trace f ) let (c, d) = f (b, d) in c
Figure 3. Arrow Loop Laws commutativity product
first f ≫ second g init i init j
= =
second g ≫ first f init (i, j)
newtype SF a b = SF { unSF :: a → (b, SF a b) } instance Arrow SF where arr f = SF h where h x = (f x, SF h) first f = SF (h f) where h f (x, z) = let (y, f’) = unSF f x in ((y, z), SF (h f’)) f >>> g = SF (h f g) where h f g x = let (y, f’) = unSF f x (z, g’) = unSF g y in (z, SF (h f’ g’))
Figure 4. Causal Commutative Arrow Laws the point-free style demanded by arrows. The above program is equivalent to the following sugar-free program: exp = fixA (integral >>> arr (+1)) where fixA f = loop (second f >>> arr (λ (_, y) → (y, y)))
Although more cumbersome, we will use this program style in the remainder of the paper, in order to avoid having to explain the meaning of arrow syntax in more detail.
instance ArrowLoop SF where loop f = SF (h f) where h f x = let ((y, z), f’) = unSF f (x, z) in (y, SF (h f’))
3. Causal Commutative Arrows In this section we introduce two key extensions to conventional arrows, and demonstrate their use by implementing a stream transformer in Haskell. First, as mentioned in the introduction, the set of arrow and arrow loop laws is not strong enough to capture stream computations. In particular, the commutativity law shown in Figure 4 establishes a non-interference property for concurrent computations – effects are still allowed, but this law guarantees that concurrent effects cannot interfere with each other. We say that an arrow is commutative if it satisfies the conventional laws as well as this critical additional law. Yampa is in fact based on commutative arrows. Second, we note that Yampa has a primitive operator called iPre that is used to inject a delay into a computation; indeed it is the primary effect imposed by the Yampa arrow [35, 21]. Similar operators, often called delay, also appear in dataflow programming [43], stream processing [39, 41], and synchronous languages [4, 8]. In all cases, the operator introduces stateful computation into an otherwise stateless setting. In an effort to make this operation more abstract, we rename it init and capture it in the following type class:
instance ArrowInit SF where init i = SF (h i) where h i x = (i, SF (h x)) runSF :: SF a b → [a] → [b] runSF f = g f where g f (x:xs) = let (y, f’) = unSF f x in y : g f’ xs
Figure 5. Causal Stream Transformer
of causal computations, namely that the current output depends only on the current as well as previous inputs. Besides causality, we make no other assumptions about the nature of these values: they may or may not vary with time, and the increment of change may be finite or infinitesimally small. More importantly, a valid instance of the ArrowInit class must satisfy the product law shown in Figure 4. This law states that two inits paired together are equivalent to one init of a pair. Here we use the *** operator instead of its expanded definition first . . . >>> second . . . to imply that the product law assumes commutativity. We will see in a later section that init and the product law are critical to our normalization and optimization strategies. But init
class ArrowLoop a ⇒ ArrowInit a where init :: b → a b b
Intuitively, the argument to init is the initial output; subsequent output is a copy of the input to the arrow. It captures the essence
37
Syntax Variables Primitive Types Types Expressions Environment
V t α, β, θ E Γ
::= ::= ::= ::= ::=
x | y | z | ... 1 | Int | Bool | . . . t|α×β|α→β |α;β V | (E1 , E2 ) | fst E | snd E | λx : α.E | E1 E2 | () | . . . x1 : α1 , . . . , xn : αn
Typing Rules (UNIT)
(APP)
Γ E1 : α → β Γ E2 : α Γ E1 E2 : β
Γ () : 1
(PAIR)
(VAR)
(x : α) ∈ Γ Γx:α
Γ E1 : α Γ E2 : β Γ (E1 , E2 ) : α × β
(ABS)
Γ, x : α E : β Γ λx : α.E : α → β
ΓE :α×β Γ fst E : α
(FST)
(SND)
Γ E :α×β Γ snd E : β
Constants arrα,β ≫ α,β,θ firstα,β,θ Definitions assoc assoc assoc −1 assoc −1 juggle juggle transpose transpose shuffle shuffle shuffle −1 shuffle −1
: = : = : = : = : = : =
: : :
(α → β) → (α ; β) (α ; β) → (β ; θ) → (α ; θ) (α ; β) → (α × θ ; β × θ)
loopα,β,θ initα
(α × β) × θ → α × (β × θ) λz.(fst (fst z), (snd (fst z), snd z)) α × (β × θ) → (α × β) × θ λz.((fst z, fst (snd z)), snd (snd z)) (α × β) × θ → (α × θ) × β assoc −1 · (id × swap) · assoc (α × β) × (θ × η) → (α × θ) × (β × η) assoc · (juggle × id) · assoc −1 α × ((β × δ) × (θ × η)) → (α × (β × θ)) × (δ × η) assoc −1 · (id × transpose ) (α × (β × θ)) × (δ × η) → α × ((β × δ) × (θ × η)) (id × transpose ) · assoc
: :
id id (·) (·) (×) (×) dup dup swap swap second second
(α × θ ; β × θ) → (α ; β) α → (α ; α)
: = : = : : : = : = : =
α→α λx.x (β → θ) → (α → β) → (α → θ) λf.λg.λx.f (g x) (α → β) → (θ → γ) → (α × θ → β × γ) λf.λg.λz.(f (fst z), g (snd z)) α→ α×α λx.(x, x) α×β →β×α λz.(snd z, fst z) (α ; β) → (θ × α ; θ × β) λf.arr swap ≫ first f ≫ arr swap
Figure 6. CCA: a language of Causal Commutative Arrows is also important in allowing us to define operators that were previously taken as domain-specific primitives. In particular, consider the integral operator used in the exponentiation examples. With init, we can define integral using the Euler integration method and a fixed global step dt as follows:
As a demonstration, we can sample the exponential function at a fixed time interval by running the exp arrow over an uniform input stream inp: dt = 0.01 inp = () : inp
:: Double :: [()]
∗Main> runSF exp inp [1.0,1.01,1.0201,1.030301,1.04060401,1.0510100501, ...
integral :: ArrowInit a ⇒ a Double Double integral = loop (arr (λ (v, i) → i + dt ∗ v) >>> init 0 >>> arr (λi → (i, i)))
We must stress that the SF type is but one instance of a causal commutative arrow, and alternative implementations such as the synchronous circuit type SeqMap in [32] and the stream function type (incidentally also called) SF in [24] also qualify as valid instances. The abstract properties such as normal forms that we develop in the next section are applicable to any of these instances, and thus are more broadly applicable than optimization techniques based on a specific semantic model, such as the one considered in [5].
To complete the picture, we give an instance (i.e. an implementation) of CCA that captures a causal stream transformer, as shown in Figure 5, where: • SF a b is an arrow representing functions (transformers) from
streams of type a to streams of type b. It is essentially a recursively defined data type consisting of a function with its continuation, a concept closely related to a form of finite state automaton called a Mealy Machine [14]. Yampa enjoys a similar implementation, and the same data type was called Auto in [32].
4. A Language of Causal Commutative Arrows
• SF is declared an instance of type classes Arrow, ArrowLoop
and ArrowInit. For example, exp can be instantiated as type exp :: SF () Double. These instances obey all of the arrow laws, including the two additional laws that we introduced.
To study the properties of CCA more rigorously, we first introduce a language of CCA terms in Figure 6. which is an extension of the simply-typed lambda calculus with a few primitives types, tuples, and arrows. Note that:
• runSF :: SF a b -> [a] -> [b] converts an SF arrow
• Although the syntax requires that we write type annotations for
into a stream transformer that maps an input stream of type [a] to an output stream of type [b].
variables in lambda abstraction, we often omit them and instead give the type of an entire expression.
38
(a) Original (a) Reorder parallel pure and stateful
(b) Reorder sequential pure and stateful
(b) Reorganized
Figure 8. Diagrams for exp (c) Change sequential composition to parallel
(d) Move sequential composition into loop
(e) Move parallel composition into loop
Figure 9. Diagram for loopB
5. Normalization of CCA In most implementations, programs written using arrows carry a runtime overhead, primarily due to the extra tupling forced onto functions’ arguments and return values. There have been several attempts [30, 24] to optimize arrow-based programs using arrow laws, but the results have not been entirely satisfactory. Although conventional arrow and arrow loop laws offer ways to combine pure arrows or collapse nested loops, they are not powerful enough to deal with effectful arrows, such as the init combinator.
(f) Fuse nested loops
Figure 7. Arrow Transformations
5.1 Intuition
• In previous examples we used the Haskell type Arrow a => a
Our new optimization strategy is based on the following rather striking observation: any CCA program can be transformed into a single loop containing one pure arrow and one initial state value. More precisely, any CCA program can be normalized into either the form arr f or:
b c to represent an arrow type a mapping from type b to type c. However, CCA does not have type classes, and thus we write α ; β instead. • Each arrow constant represents a family of constant arrow func-
tions indexed by types. We’ll omit the type subscripts when they are obvious from context.
loop(arr f ≫ second (second (init i))) where f is a pure function and i is an initial state. Note that all other arrow combinators, and therefore all of the overheads associated with them (tupling, etc.) are completely eliminated. Not surprisingly, the resulting improvement in performance is rather dramatic, as we will see later. We treat the loop combinator not just as a way to provide feedback from output to input, but also as a way to reorganize a complex composition of arrows. To see how this works, it is helpful to visualize a few examples, as shown in Figure 7, and explained below. This should help explain the intuition behind our normalization process, which is treated formally in the next section. The diagrams in Figure 7 can be explained as follows:
The figure also defines a set of commonly used auxiliary functions. Besides satisfying the usual beta law for lambda expressions, arrows in CCA also satisfy the nine conventional arrow laws (Figure 2), the six arrow loop laws (Figure 3), and the two causal commutative arrow laws (Figure 4). Due to the existence of immediate feedback in loops, CCA is able to make use of general recursion that is not allowed in the simply typed lambda calculus. To see why immediate feedback is necessary, we can look back at the fixA function used to define the combinator version of exp. We rewrite it using CCA syntax below: fixA : (α ; α) → (β ; α) fixA = λf.loop(second f ≫ arr (λx.(snd x, snd x)))
(a) Re-order parallel pure and stateful arrows. Figure 7(a) shows the exchange law for arrows, which is a special case of the commutativity law, and useful for re-ordering pure and stateful arrows.
It computes a fixed point of an arrow at the value level, and contains no init in its definition. We consider the ability to model general recursion a strength of our work that is often lacking in other stream or dataflow programming languages.
(b) Re-order sequential pure and stateful arrows. Figure 7(b) shows how the immediate feedback of the loop combinator
39
loop init composition extension left tightening right tightening vanishing superposing
loop f init i arr f ≫ arr g first (arr f ) h ≫ loopB i f loopB i f ≫ arr g loopB i (loopB j f ) first (loopB i f )
→ → → → → → → →
loopB () (arr assoc −1 ≫ first f ≫ arr assoc) loopB i (arr (swap · juggle · swap)) arr (g · f ) arr (f × id) loopB i (first h ≫ f ) loopB i (f ≫ first (arr g)) loopB (i, j) (arr shuffle ≫ f ≫ arr shuffle −1 ) loopB i (arr juggle ≫ first f ≫ arr juggle)
Figure 10. One Step Reduction for CCA
(NORM)
e⇓e
(SEQ) (FIRST) (LOOP)
f ⇓ f
∃(i, f ) s.t. e = arr f or e = loopB i (arr f )
e1 ⇓ e1
e2 ⇓ e2 e1 ≫ e2 → e e1 ≫ e2 ⇓ e
first f → e e ⇓ e first f ⇓ e
loop f → e e ⇓ e loop f ⇓ e
(INIT)
(LOOPB)
f ⇓ f
e ⇓ e init i → e e ⇓ e init i ⇓ e loopB i f → e loopB i f ⇓ e
e ⇓ e
Figure 11. Normalization Procedure for CCA helps to re-order arrows. This follows from the definition of second , and the tightening and sliding laws for loops.
initialized before looping back, and is often regarded as an internal state. The value of type γ is immediately fed back and often used for general recursions at the value level. We define a single step reduction → as a set of rules in Figure 10, and a normalization procedure in Figure 11. The normalization relation ⇓ can be seen as a big step reduction following an innermost strategy, and is indeed a function. Note that some of the reduction rules resemble the arrow laws of the same name. However, there are some subtle but important differences: First, unlike the laws, reduction is directed. Second, the rules are extended to handle loopB instead of loop. Finally, they are adjusted to avoid overlaps.
(c) Change sequential composition to parallel. Figure 7(c) shows that in addition to the sequential re-ordering we can use the product law to fuse two stateful computations into one. (d) Move sequential composition into loop. Figure 7(d) shows the left-tightening law for loops. Because the first arrow can also be a loop, we are able to combine sequential compositions of two loops into a nested one. (e) Move parallel composition into loop. Figure 7(e) shows a variant of the superposing law for loops using first instead of second . Since we know that parallel composition can be decomposed into first and second, and if each of them can be transformed into a loop, they will eventually be combined into a nested loop as shown.
Theorem 5.1 (CCNF) For all e : α ; β, there exists a normal form enorm , called the Causal Commutative Normal Form, which is either of the form arr f , or loopB i (arr f ) for some i and f , such that enorm : α ; β, and e ⇓ enorm . In unsugared form, the second form is equivalent to:
(f) Fuse nested loops. Figure 7(f) shows an extension of the vanishing law for loops to handle stateful computations. Its proof requires the commutative law and product law to switch the position of two stateful arrows and join them together.
loop(arr f ≫ second (second (init i)))
As a concrete example, Figure 8(a) is a diagram of the original exp example given earlier. In Figure 8(b) we have unfolded the definition of integral and applied the optimization strategy. The result is a single loop, where all pure functions can be combined together to minimize arrow implementation overheads.
Proof: Follows directly from Lemmas 5.1 and 5.2. 2 Note that we only consider closed terms with empty type environments in Theorem 5.1, otherwise we would have to include lambda normal forms as part of CCNF. For example, x : α ; β x : α ; β would qualify as a valid CCNF since x is of an arrow type, and there is no further reduction possible. Although this addition may be needed in real implementations, it would unnecessarily complicate the discussion, so we disallow open terms for simplicity.
5.2 Algorithm In this section we give a formal definition of the normalization procedure. First we define a combinator called loopB that can be viewed as syntactic sugar for handling both immediate and delayed feedback: loopB : θ → (α × (γ × θ) ; β × (γ × θ)) → (α ; β) loopB = λi.λf.loop (f ≫ second (second (init i)))
Lemma 5.1 (Soundness) The reduction rules given in Figure 10 are both type and semantics preserving, i.e., if e → e then e = e is syntactically derivable from the set of CCA laws.
A pictorial view of loopB is given in Figure 9. The second argument to loopB is an arrow mapping from an input of type α to output β, while looping over a pair γ × θ. The value of type θ is
Proof: By equational reasoning using arrow laws. The loop and init rules follow from the definition of loopB ; composition and
40
runCCNF :: e → ((b, e) → (c, e)) → [b] → [c] runCCNF i f = g i where g i (x:xs) = let (y, i’) = f (x, i) in y : g i’ xs
extension are directly based on the arrow laws with the same name; left and right tightening and superposing rules follow the definition of loopB , the commutativity law and the arrow loop laws with the same name. The proof of the vanishing rule is more involved, and is given in Appendix B. 2 Note that the set of reduction rules is sound but not complete, because the loop combinator can introduce general recursion at the value level.
runCCNF essentially converts an optimized CCNF term directly into a stream transformer. In doing so, we have successfully transformed away all arrow instances, including the data structure used to implement them! The result is of course no longer abstract, and is closely tied to the low-level representation of streams.
Lemma 5.2 (Termination) The normalization procedure for CCA given in Figure 11 terminates for all well-typed arrow expressions e : α ; β.
Combining CCA With Stream Fusion We can perform even more aggressive optimizations on CCNF by borrowing the stream representation and optimization techniques introduced by Coutts et al. [12]. First, we define a datatype to encapsulate a stream as a product of a stepper function and an initial state:
Proof: By structural induction over all possible combinations of well-typed arrow terms. See Appendix A for details. 2
data Stream a = forall s. Stream (s → Step a s) s data Step a s = Yield a s
6. Further Optimization
Here a is the element type and s is an existentially quantified state type. For our purposes, we have simplified the return type of the original stepper function in [12]. Our stepper function essentially consumes a state and yields an element in the stream paired with a new state. The key to effective fusion is that all stream producers must be non-recursive. In other words, a recursively defined stream such as exp should be written in terms of non-recursive stepper functions, with recursion deferred until the stream is unfolded. Programs written in this style can then be fused by the compiler into a tailrecursive loop, at which point tail-call eliminations and various unboxing optimizations can be easily applied. This is where CCA and our normalization procedure fit together so nicely. We can take advantage of the arrow syntax to write recursive code, and rely on the arrow translator to express it nonrecursively using the loop combinator. We then normalize it into CCNF, and rewrite it in terms of streams. The last step is surprisingly straightforward. We introduce yet another loop combinator loopS that closely resembles loopD:
We have implemented the normalization procedure of CCA in Haskell. In fact the normalization of an arrow term does not have to stop at CCNF, because pure functions in the language are of simply typed lambda calculus, which is strongly normalizing. Extra care was taken to preserve sharing of lambda terms, to eliminate redundant variables, and so on. In the remainder of this section we describe a simple sequence of other optimizations that ultimately leads to a single imperative loop that can be implemented extremely efficiently. Optimized Loop In addition to loopB, for optimization purposes we introduce another looping combinator, loopD, for loops with only delayed feedback. For comparison, the Haskell definitions of both are given below: loopB :: ArrowInit a ⇒ e → a (b, (d, e)) (c, (d, e)) → a b c loopD :: ArrowInit a ⇒ e → a (b, e) (c, e) → a b c loopB i f = loop (f >>> second (second (init i))) loopD i f = loop (f >>> second (init i))
loopS :: t → ((a, t) → (b, t)) → Stream a → Stream b loopS z f (Stream next0 s0) = Stream next (z, s0) where next (i, s) = case next0 s of Yield x s’ → Yield y (z’, s’) where (y, z’) = f (x, i)
The reason to introduce loopD is that many applications of CCA result in an arrow in which all loops only have delayed feedback. For example, after removing redundant variables, normalizing lambdas, and eliminating common sub-expressions, the CCNF for exp is: exp’ = loopB 0 (arr (λ (x, (z, y)) → let i = y + 1 in (i, (z, y + dt ∗ i))))
Intuitively, loopS is the unlifted version of loopD. The initial state of the output stream consists of the initial feedback value z and the state of the input stream. As the resulting stream gets unfolded, it supplies f with an input tuple and carries the output along with the next state of the input stream. In general, we can rewrite terms of the form loopD i (arr f ) into loopS i f for some i and f . To illustrate this, let us revisit the exp example. We take the optimized CCNF exp’’ and rewrite it in terms of loopS as expOpt:
Clearly the variable z here is redundant, and it can be removed by changing loopB to loopD: exp’’ = loopD 0 (arr (λ (x, y) → let i = y + 1 in (i, y + dt ∗ i)))
The above function corresponds nicely with the diagram shown in Figure 8(b). We call this result optimized CCNF. Inlining Implementation In fact loopD can be made even more efficient if we expose the underlying arrow implementation. For example, using the SF data type shown in Figure 5, loopD can be defined as:
expOpt :: Stream Double expOpt sr = loopS 0 (λ (x, y) → let i = y + 1 in (i, y + dt ∗ i)) (constS ())
loopD i f = SF (g i f) where g i f x = let ((y, i’), f’) = unSF f (x, i) in (y, SF (g i’ f’))
constS :: a → Stream a constS c = Stream next () where next _ = Yield c ()
Since the resulting stream producer ignores any input, we define constS to supply a stream of unit values. This does not negatively impact performance, as the compiler is able to remove the dummy values eventually. To extract elements from a stream, we can write a tail-recursive function to unfold it. For example, the function nth extracts the nth element from a stream:
Also, if we examine the use of loopD in optimized CCNF, we notice that the arrow it takes is always a pure arrow, and hence we can drop the arrow and use the pure function instead. Furthermore, if our interest is just in computing from an input stream to an output, we can drop the intermediate SF data structure altogether, thus yielding:
41
nth :: Int → Stream a → a nth n (Stream next0 s0) = go n s0 where go n s = case next0 s of Yield x s’ → if n == 0 then x else go (n-1) s’ e2 :: Double e2 = nth 2 expOpt
Name (LOC) exp (4) sine (6) oscSine (4) 50’s sci-fi (5) robotSim (8)
1. GHC 1.0 1.0 1.0 1.0 1.0
2. arrowp 2.4 2.66 1.75 1.28 1.48
3. CCNF 13.9 12.0 4.1 10.2 8.9
4. Stream 190.9 284.0 13.0 19.2 36.8
Figure 12. Performance Ratio (greater is better)
-- 1.0201
We can define unfolding functions other than nth in a similar manner. With the necessary optimization options turned on, GHC fuses nth and expOpt into a tail-recursive loop. The code below shows the equivalent intermediate representation extracted from GHC after optimization. It uses only strict and unboxed types (Int# and Double#).
intermediate data structures, and can be orders of magnitude faster than its arrow-based predecessors. 3. GHC’s arrow syntax translator does not do as well as Paterson’s original translator for the sample programs we chose, though both are significantly outperformed by our normalization techniques.
go :: Int# → Double# → Double# go n y = case n of __DEFAULT → go (n - 1) (y + dt ∗ (y + 1.0)) 0 → y + 1.0
8. Discussion Our key contribution is the discovery of a normal form for core Yampa, or CCA, programs: any CCA program can be transformed into a single loop with just one pure (and strongly normalizing) function and a set of initial states. This discovery is new and original, and has practical implications in implementing not just Yampa, but a broader class of synchronous dataflow languages and stream computations because this property is entirely based on axiomatic laws, not any particular semantic model. We discuss such relevance and related topics to our approach below.
e2 :: Double e2 = D# (go 2 0.0)
In summary, employing stream fusion, the GHC compiler can turn any CCNF into a tight imperative loop that is free of all cons cell and closure allocations. This results in a dramatic speedup for CCA programs and eliminates the need for heap allocation and garbage collection. In the next section we quantify this claim via benchmarks.
8.1 Alternative Formalisms Apart from arrows, other formalisms such as monads, comonads and applicative functors have been used to model computations over data streams [3, 42, 28]. Central to many of these approaches are the representation of streams and computations about them. However, notably missing are the connections between stream computation and the related laws. For example, Uustalu’s work [42] concluded that comonad is a suitable model for dataflow computation, but it lacks any discussion of whether the comonadic laws are of any relevance. In contrast, it is the very idea of making sense out of arrow and arrow loop laws that motivated our work. We argue that arrows are a suitable abstract model for stream computation not only because we can implement stream functions as arrows, but also because abstract properties like the arrow laws help to bring more insights to our target application domain. Besides having to satisfy respective laws for these formalisms, each abstraction has to introduce domain specific operators, otherwise it would be too general to be useful. With respect to causal streams, many have introduced init (also known as delay) as a primitive to enable stateful computation, but few seem to have made the connection of its properties to program optimizations. Notably the product law we introduced for CCA relates to a bisimilarity property of co-algebraic streams, i.e., the product of two initialized streams are bisimilar to one initialized stream of product.
7. Benchmarks We ran a set of benchmarks to measure the performance of several programs written in arrow syntax, but compiled and optimized in different ways. For each program, we: 1. Compiled with GHC, which has a built-in translator for arrow syntax. 2. Translated using Paterson’s arrowp pre-processor to arrow combinators, and then compiled with GHC. 3. Normalized into CCNF combinators, and compiled with GHC. 4. Normalized into CCNF combinators, rewritten in terms of streams, and compiled with GHC using stream fusion. The five benchmarks we used are: the exponential function given earlier, a sine wave with fixed frequency using Goertzel’s method, a sine wave with variable frequency, “50’s sci-fi” sound synthesis program taken from [15], and a robot simulator taken from [21]. The programs were compiled and run on an Intel Core 2 Duo machine with GHC version 6.10.1, using the C backend code generator and -O2 optimization. We measured the CPU time used to run a program through 106 samples. The results are shown in Figure 12, where the numbers represent normalized speedup ratios, and we also include the lines of code (LOC) for the source program. The results show dramatic performance improvements using normalized arrows. We note that:
8.2 Co-algebraic streams The co-algebraic property of streams is well known, and most relevant to our work is Caspi and Pouzet’s representation of stream and stream functions in a functional language setting [5], which also uses a primitive similar to the trace operator (and hence the arrow loop combinator) to model recursion. Their compilation technique, however, lacks a systematic approach to optimize nested recursions. We consider our technique more effective and more abstract.
1. Based on the same arrow implementation, the performance gain of CCNF over the first two approaches is entirely due to program transformations at the source level. This means that the runtime overhead of arrows is significant, and cannot be neglected for real applications. 2. The stream representation of CCNF produces high-performance code that is completely free of dynamic memory allocation and
42
Most synchronous languages, including the one introduced in [5], are able to compile stream programs into a form called single loop code by performing a causality analysis to break the feedback loop of recursively defined values. Many efforts have been made to generate efficient single loop code [16, 1], but to our best knowledge there has not been a strong result like normal forms. Our discovery of CCNF is original, and the optimization by normalization approach is both systematic and deterministic. Together with stream fusion, we produce a result that is not just a single loop, but a highly optimized one. Also relevant is Rutten’s work on high-order functional stream derivatives [38]. We believe that arrows are a more general abstraction than functional stream derivatives, because the latter still exposes the structure of a stream. Moreover, arrows give rise to a high-level language with richer algebraic properties than the 2-adic calculus considered in [38].
fib_stream :: Stream Int fib_stream = Stream next (0, 1) where next (a, b) = Yield r (b, r) where r = a + b f1 :: Int f1 = nth 5 fib_stream
-- 13
Stream fusion will fuse nth and fib stream to produce an efficient loop. For a comparison, with our technique the arrow version of the Fibonacci sequence shown below compiles to the same efficient loop as f1 above, and yet retains the benefit of being abstract and concise. fibA = proc _ → rec let r = d2 d1 ← init d2 ← init returnA −≺ r
8.3 Expressiveness
do + d1 0 −≺ d2 1 −≺ r
We must stress that writing stepper functions is not always as easy as in trivial examples like fib and exp. Most non-trivial stream programs that we are concerned with contain many recursive parts, and expressing them in terms of combinators in a nonrecursive way can get unwieldy. Moreover, this kind of coding style exposes a lot of operational details which are arguably unnecessary for representing the underlying algorithm. In contrast, arrow syntax relieves the burden of coding in combinator form and allows recursion via the rec keyword. It also completely hides the actual implementation of the underlying stream structure and is therefore more abstract. The strength of CCA is the ability to normalize any causal and recursive stream function. Combining both fusion and our normalization algorithm, any CCA program can be reliably and predictably optimized into an efficient machine-friendly loop. The process can be fully automated, allowing programmers to program at an abstract level while getting performance competitive to programs written in low-level imperative languages.
It is known that operationally a Mealy machine is able to represent all causal stream functions [38], while the CCA language defined in Figure 6 represents only a subset. For example, the switch combinator introduced in Yampa [21] is able to dynamically replace a running arrow with a new one depending on an input event, and hence to switch the system behavior completely. With CCA, there is no way to change the compositional structure of the arrow program itself at run time. For another example, many dataflow and stream programming languages also provide conditionals, such as if-then-else, as part of the language [43, 4]. To enable conditionals at the arrow level, we need to further extend CCA to be an instance of the ArrowChoice class. Both are worthy extensions to consider for future work. It should also be noted that the local state introduced by init is one of the minimal side effects one can introduce to arrow programs. The commutativity law for CCA ensures that the effect of one arrow cannot interfere with another when composed together, and it is no longer satisfiable when such ordering becomes important, e.g., when arrows are used to model parsers and printers [25]. On the other hand, because the language for CCA remains highly abstract, it could be applicable to domains other than FRP or dataflow. We’ll leave such findings to future work.
Acknowledgements This research was supported in part by NSF grants CCF-0811665 and CNS-0720682, and a grant from Microsoft Research.
References
8.4 Stream fusion
[1] Pascalin Amagbegnon, Loc Besnard, and Paul Le Guernic. Implementation of the data-flow synchronous language signal. In In Conference on Programming Language Design and Implementation, pages 163–173. ACM Press, 1995.
Stream fusion can help fuse zips, left folds, and nested lists into efficient loops. But on its own, it does not optimize recursively and lazily defined streams effectively. Consider a stream generating the Fibonacci sequence. It is one of the simplest classic examples that characterizes stateful stream computation. One way of writing it in Haskell is to exploit laziness and zip the stream with itself:
[2] Robert Atkey. What is a categorical model of arrows? Mathematically Structured Functional Programming, 2008.
In
[3] Per Bjesse, Koen Claessen, Mary Sheeran, and Satnam Singh. Lava: Hardware design in haskell. In ICFP, pages 174–184, 1998. [4] P. Caspi, N. Halbwachs, D. Pilaud, and J.A. Plaice. Lustre: A declarative language for programming synchronous systems. In 14th ACM Symp. on Principles of Programming Languages, January 1987.
fibs :: [Int] fibs = 0:1:zipWith (+) fibs (tail fibs)
While the code is concise and elegant, such programming style relies too much on the definition of an inductively defined structure. The explicit sharing of the stream fibs in the definition is a blessing and a curse. On one hand, it runs in linear time and constant space. On the other hand, the presence of the stream structure gets in the way of optimization. None of the current fusion or deforestation techniques are able to effectively eliminate cons cell allocations in this example. Real-world stream programs are usually much more complex and involve more feedback, and the time spent in allocating intermediate structure and by the garbage collector could degrade performance significantly. We can certainly write a stream in stepper style that generates the Fibonacci sequence:
[5] Paul Caspi and Marc Pouzet. A Co-iterative Characterization of Synchronous Stream Functions. In Coalgebraic Methods in Computer Science (CMCS’98), Electronic Notes in Theoretical Computer Science, March 1998. Extended version available as a VERIMAG tech. report no. 97–07 at www.lri.fr/∼pouzet. [6] Eric Cheng and Paul Hudak. Look ma, no arrows – a functional reactive real-time sound synthesis framework. Technical Report YALEU/DCS/RR-1405, Yale University, May 2008. [7] Mun Hon Cheong. Functional programming and 3d games, November 2005. also see www.haskell.org/haskellwiki/Frag. [8] Jean-Louis Colac¸o, Alain Girault, Gr´egoire Hamon, and Marc Pouzet. Towards a higher-order synchronous data-flow language. In
43
[28] Conor McBride and Ross Paterson. Applicative programming with effects. J. Funct. Program., 18(1):1–13, 2008.
EMSOFT ’04: Proceedings of the 4th ACM international conference on Embedded software, pages 230–239, New York, NY, USA, 2004. ACM.
[29] Eugenio Moggi. Notions of computation and monads. Inf. Comput., 93(1):55–92, 1991.
[9] Antony Courtney. Modelling User Interfaces in a Functional Language. PhD thesis, Department of Computer Science, Yale University, May 2004.
[30] Henrik Nilsson. Dynamic optimization for functional reactive programming using generalized algebraic data types. In ICFP, pages 54–65, 2005.
[10] Antony Courtney and Conal Elliott. Genuinely functional user interfaces. In Proc. of the 2001 Haskell Workshop, September 2001.
[31] Clemens Oertel. RatTracker: A Functional-Reactive Approach to Flexible Control of Behavioural Conditioning Experiments. PhD thesis, Wilhelm-Schickard-Institute for Computer Science at the University of T¨ubingen, May 2006.
[11] Antony Courtney, Henrik Nilsson, and John Peterson. The Yampa arcade. In Proceedings of the 2003 ACM SIGPLAN Haskell Workshop (Haskell’03), pages 7–18, Uppsala, Sweden, August 2003. ACM Press.
[32] Ross Paterson. A new notation for arrows. In ICFP’01: International Conference on Functional Programming, pages 229–240, Firenze, Italy, 2001.
[12] Duncan Coutts, Roman Leshchinskiy, and Don Stewart. Stream fusion: From lists to streams to nothing at all. In Proceedings of the ACM SIGPLAN International Conference on Functional Programming, ICFP 2007, April 2007.
[33] John Peterson, Gregory Hager, and Paul Hudak. A language for declarative robotic programming. In International Conference on Robotics and Automation, 1999.
[13] Conal Elliott and Paul Hudak. Functional reactive animation. In International Conference on Functional Programming, pages 263– 273, June 1997.
[34] John Peterson, Paul Hudak, and Conal Elliott. Lambda in motion: Controlling robots with Haskell. In First International Workshop on Practical Aspects of Declarative Languages. SIGPLAN, Jan 1999.
[14] G. H. Mealy. A method for synthesizing sequential circuits. Bell System Technical Journal, 34(5):1045–1079, 1955.
[35] John Peterson, Zhanyong Wan, Paul Hudak, and Henrik Nilsson. Yale FRP User’s Manual. Department of Computer Science, Yale University, January 2001. Available at http://www.haskell.org/ frp/manual.html.
[15] George Giorgidze and Henrik Nilsson. Switched-on yampa. In Paul Hudak and David Scott Warren, editors, Practical Aspects of Declarative Languages, 10th International Symposium, PADL 2008, San Francisco, CA, USA, January 7-8, 2008, volume 4902 of Lecture Notes in Computer Science, pages 282–298. Springer, 2008.
[36] Simon Peyton Jones et al. The Haskell 98 language and libraries: The revised report. Journal of Functional Programming, 13(1):0–255, Jan 2003. http://www.haskell.org/definition/.
[16] N. Halbwachs, P. Raymond, and C. Ratel. Generating efficient code from data-flow programs. In J. Maluszy´nski and M. Wirsing, editors, Proceedings of the Third International Symposium on Programming Language Implementation and Logic Programming, number 528, pages 1–13207–218. Springer Verlag, 1991.
[37] John Power and Hayo Thielecke. Closed freyd- and kappa-categories. In ICALP, pages 625–634, 1999. [38] Jan J. M. M. Rutten. Algebraic specification and coalgebraic synthesis of mealy automata. Electr. Notes Theor. Comput. Sci, 160:305–319, 2006.
[17] Masahito Hasegawa. Recursion from cyclic sharing: traced monoidal categories and models of cyclic lambda calculi. pages 196–213. Springer Verlag, 1997.
[39] Robert Stephens. A survey of stream processing. Acta Informatica, 34(7):491–541, 1997.
[18] L. Huang, P. Hudak, and J. Peterson. Hporter: Using arrows to compose parallel processes. In Proc. Practical Aspects of Declarative Languages, pages 275–289. Springer Verlag LNCS 4354, January 2007.
[40] Ross Howard Street, A. Joyal, and D. Verity. Traced monoidal categories. Mathematical Proceedings of the Cambridge Philosophical Society, 119(3):425–446, 1996. [41] William Thies, Michal Karczmarek, and Saman P. Amarasinghe. Streamit: A language for streaming applications. In CC ’02: Proceedings of the 11th International Conference on Compiler Construction, pages 179–196, London, UK, 2002. Springer-Verlag.
[19] P. Hudak. Building domain specific embedded languages. ACM Computing Surveys, 28A:(electronic), December 1996. [20] Paul Hudak. Modular domain specific languages and tools. In Proceedings of Fifth International Conference on Software Reuse, pages 134–142. IEEE Computer Society, June 1998.
[42] Tarmo Uustalu and Varmo Vene. The essence of dataflow programming. In Zolt´an Horv´ath, editor, CEFP, volume 4164 of Lecture Notes in Computer Science, pages 135–167. Springer, 2005.
[21] Paul Hudak, Antony Courtney, Henrik Nilsson, and John Peterson. Arrows, robots, and functional reactive programming. In Summer School on Advanced Functional Programming 2002, Oxford University, volume 2638 of Lecture Notes in Computer Science, pages 159–187. Springer-Verlag, 2003.
[43] William W. Wadge and Edward A. Ashcroft. LUCID, the dataflow programming language. Academic Press Professional, Inc., San Diego, CA, USA, 1985.
[22] Paul Hudak, Paul Liu, Michael Stern, and Ashish Agarwal. Yampa meets the worm. Technical Report YALEU/DCS/RR-1408, Yale University, July 2008.
A. Proof for the termination lemma Proof: We will show that the there always exists a enorm for well formed arrow expression e : α ; β, and the normalization procedure always terminates. This is done by structural induction over all possible arrow terms, and any closed expression e that’s not already in arrow terms shall be first beta reduced.
[23] John Hughes. Generalising monads to arrows. Science of Computer Programming, 37:67–111, May 2000. [24] John Hughes. Programming with arrows. In Advanced Functional Programming, pages 73–129, 2004. [25] Patrik Jansson and Johan Jeuring. Polytypic compact printing and parsing. In ESOP, pages 273–287, 1999.
1. e = arr f It already satisfies the termination condition.
[26] Sam Lindley, Philip Wadler, and Jeremy Yallop. The arrow calculus (functional pearl). Draft, 2008.
2. e = first f By induction hypothesis, f ⇓ arr f , or f ⇓ loopB i (arr f ), where f and f are pure functions. In the first case by extension rule first f → arr (f × id) and terminates; In the second case
[27] Hai Liu and Paul Hudak. Plugging a space leak with an arrow. Electronic Notes in Theoretical Computer Science, 193:29–45, nov 2007.
44
juggle · f · juggle · shuffle))
first f ∗ first (loopB i(arr f )) → superposing →loopB i (arr juggle ≫ arr f ≫ arr juggle) composition →loopB i (arr (juggle · f juggle))
4. e = loop f By induction hypothesis, f ⇓ arr f or f ⇓ loopB i (arr f ). In the first case loop f ∗ → loop (arr f ) loop →loopB ()(arr assoc −1 ≫ arr f ≫ arr assoc) composition →loopB ()(arr (assoc · f · assoc −1 ))
and terminates. 3. e = f ≫ g By induction hypothesis, f ⇓ arr f or f ⇓ loopB i (arr f ), and g ⇓ arr g or g ⇓ loopB i (arr g ). So there are 4 combinations, and in all cases they terminate.
and terminates. In the second case loop f ∗ → loop (loopB i (arr f )) loop →loopB ()(arr assoc −1 ≫ loopB i (arr f ) ≫ arr assoc) left and right tightening ∗ loopB ()(loopB i (first (arr assoc −1 ) ≫ arr f ≫ → first (arr assoc))) extension and composition ∗ loopB ()(loopB i (arr ((assoc × id)· → f · (assoc −1 × id )))) vanishing →loopB ((), i)(arr shuffle ≫ arr ((assoc × id )· f · (assoc −1 × id )) ≫ arr shuffle −1 ) composition →loopB ((), i)(arr (shuffle −1 · (assoc × id ) · f · (assoc −1 × id) · shuffle))
1)
f≫g ∗ → arr f ≫ arr g composition →arr (g · f ) 2)
f≫g ∗ arr f ≫ loopB i (arr g ) → left tightening →loopB i (first (arr f ) ≫ arr g ) extension →loopB i (arr (f × id) ≫ arr g ) composition →loopB i (arr (g · (f × id ))) 3)
f≫g ∗ loopB i (arr f ) ≫ arr g → right tightening →loopB i (arr f ≫ first (arr g )) extension →loopB i (arr f ≫ arr (g × id)) composition →loopB i (arr ((g × id) · f ))
and terminates. 5. e = init i By init rule, init i → loopB i (arr (swap · juggle · swap)) and terminates. 2
B. Proof for the vanishing rule of loopB
4)
Proof: We will show that
f≫g →loopB i (arr f ) ≫ loopB i (arr g ) left tightening →loopB j (first (loopB i (arr f )) ≫ arr g ) superposing →loopB j (loopB i (arr juggle ≫ arr f ≫ arr juggle) ≫ arr g ) composition ∗ → loopB j (loopB i (arr (juggle · f · juggle)) ≫ arr g ) right tightening →loopB j (loopB i (arr (juggle · f · juggle) ≫ first (arr g ))) extension →loopB j (loopB i (arr (juggle · f · juggle) ≫ arr (g × id))) composition →loopB j (loopB i (arr ((g × id) · juggle ·f · juggle))) vanishing →loopB (j, i) (arr shuffle ≫ arr ((g × id ) · juggle · f · juggle) ≫ arr shuffle −1 ) composition ∗ loopB (j, i) (arr (shuffle −1 · (g × id)· → ∗
loopB i (loopB j f ) = loopB (i, j) (arr shuffle ≫ f ≫ arr shuffle −1 ) by equational reasoning.
= = = = =
=
45
loopB i (loopB j f ) definition of loopB loop (loopB j f ≫ second (second (init i))) definition of loopB loop (loop (f ≫ second (second (init j))) ≫ second (second (init i))) right tightening of loop loop (loop (f ≫ second (second (init j)) ≫ f irst(second (second (init i))))) commutativity loop (loop (f ≫ f irst(second (second (init i))) ≫ second (second (init j)))) vanishing of loop loop (arr assoc −1 ≫ f ≫ first (second (second (init i))) ≫ second (second (init j)) ≫ arr assoc) Lemma B.1 loop (arr assoc −1 ≫ f ≫ arr shuffle −1 ≫ second (second (init (i, j))) ≫ arr shuffle ≫ arr assoc)
shuffle−1 · shuffle = id = loop (arr (shuffle −1 · assoc −1 ) ≫ arr shuffle ≫ f ≫ arr shuffle −1 ≫ second (second (init (i, j))) ≫ arr shuffle ≫ arr assoc) shuffle−1 · assoc−1 = id × transpose = loop (arr (id × transpose ) ≫ arr shuffle ≫ f ≫ arr shuffle −1 ≫ second (second (init (i, j))) ≫ arr shuffle ≫ arr assoc) sliding = loop (arr shuffle ≫ f ≫ arr shuffle −1 ≫ second (second (init (i, j))) ≫ arr shuffle ≫ arr assoc ≫ arr (id × transpose )) shuffle−1 = (id × transpose) · assoc = loop (arr shuffle ≫ f ≫ arr shuffle −1 ≫ second (second (init (i, j))) ≫ arr shuffle ≫ arr shuffle −1 ) shuffle · shuffle−1 = id = loop (arr shuffle ≫ f ≫ arr shuffle −1 ≫ second (second (init (i, j)))) definition of loopB = loopB (i, j)(arr shuffle ≫ f ≫ arr shuffle −1 )
arr ((swap × id ) · assoc −1 · (swap × id ) · assoc −1 ) composition and normalization = arr (λ((a, (c, d)), (b, e)).(d, (e, ((c, b), a)))) ≫ first (init i) ≫ arr (λ(d, (e, ((c, b), a))).((a, (c, d)), (b, e))) and from lhs:
=
=
=
=
Lemma B.1 first (second (second (init i))) ≫ second (second (init j)) = arr shuffle −1 ≫ second (second (init(i, j))) ≫ arr shuffle
=
Proof: We first show =
Hence lhs = rhs. Using similar technique, we can also prove (details omitted to save space)
first (second (second (init i))) arr shuffle −1 ≫ second (second (first (init i))) ≫ arr shuffle
second (second (init j)) = arr shuffle −1 ≫ second (second (second (init j))) ≫ arr shuffle
This can be done by equational reasoning from both sides. From lhs:
= =
=
=
=
=
=
arr shuffle −1 ≫ second (second (first (init i))) ≫ arr shuffle definition of second arr shuffle −1 ≫ arr swap ≫ first (arr swap ≫ first (first (init i)) ≫ arr swap) ≫ arr swap ≫ arr shuffle functor and extension arr (swap · shuffle −1 ) ≫ arr (swap × id ) ≫ first (first (first (init i))) ≫ arr (swap × id) ≫ arr (shuffle · swap) association arr ((swap × id ) · swap · shuffle −1 ) ≫ arr assoc ≫ arr assoc ≫ first (init i) ≫ arr assoc −1 ≫ arr assoc −1 ≫ arr (shuffle · swap · (swap × id)) composition arr (assoc · assoc · (swap × id) · swap · shuffle −1 ) ≫ first (init i) ≫ arr (shuffle · swap · (swap × id) · assoc −1 · assoc −1 ) normalization arr (λ((a, (c, d)), (b, e)).(d, (e, ((c, b), a)))) ≫ first (init i) ≫ arr (λ(d, (e, ((c, b), a))).((a, (c, d)), (b, e)))
Therefore we have
first (second (second (init i))) definition of second first (arr swap ≫ first (arr swap ≫ first (init i) ≫ arr swap) ≫ arr swap) functor and extension arr (swap × id) ≫ first (first (arr swap ≫ first (init i) ≫ arr swap)) ≫ arr (swap × id) association arr (swap × id) ≫ arr assoc ≫ first (arr swap ≫ first (init i) ≫ arr swap) ≫ arr assoc −1 ≫ arr (swap × id) functor and extension arr (assoc · (swap × id)) ≫ arr (swap × id) ≫ first (first (init i)) ≫ arr (swap × id) ≫ arr ((swap × id) · assoc −1 ) association arr ((swap × id) · assoc · (swap × id)) ≫ arr assoc ≫ first (init i) ≫ arr assoc −1 ≫ arr ((swap × id) · assoc −1 · (swap × id )) composition arr (assoc · (swap × id) · assoc · (swap × id)) ≫ first (init i) ≫ arr ((swap × id) · assoc −1 · (swap × id ) · assoc −1 ) Lemma B.2 arr (assoc · (swap × id) · assoc · (swap × id)) ≫ arr (id × (swap · assoc −1 · transpose · assoc −1 )) ≫ first (init i) ≫ arr (id × (assoc · transpose · assoc · swap)) ≫
first (second (second (init i))) ≫ second (second (init j)) substitution = arr shuffle −1 ≫ second (second (first (init i))) ≫ arr shuffle ≫ arr shuffle −1 ≫ second (second (second (init i))) ≫ arr shuffle shuffle · shuffle−1 = id = arr shuffle −1 ≫ second (second (first (init i))) ≫ second (second (second (init i))) ≫ arr shuffle functor and product = arr shuffle −1 ≫ second (second (init(i, j))) ≫ arr shuffle 2 Lemma B.2 ∀g, g −1 , g · g −1 = id , we have first f = arr (id × g) ≫ first f ≫ arr (id × g −1 ) Proof:
= = = =
arr (id × g) ≫ first f ≫ arr (id × g −1 ) exchange first f ≫ arr (id × g) ≫ arr (id × g −1 ) composition first f ≫ arr ((id × g −1 ) · (id × g)) normalization first f ≫ arr id right identity first f 2
46
A Functional I/O System ∗ or, Fun for Freshman Kids Matthias Felleisen Northeastern University
Robert Bruce Findler Northwestern University
Matthew Flatt University of Utah
Abstract
Shriram Krishnamurthi Brown University
Surprisingly, even O’Sullivan et al. (2008)’s Real World Haskell has difficulties explaining I/O, according to some on-line reviews. Here we present our approach to reconciling I/O with purely functional programming, especially for a pedagogical setting. The I/O framework extends the DrScheme (Findler et al. 2002) teaching languages for our text How to Design Programs (HtDP) (Felleisen et al. 2001), but it is also accessible from other dialects. Our framework does not require the use of any monads or other threading devices, meaning middle school students can write animation and interactive games as a bunch of mathematical functions. Indeed, because everything is just a function on numbers, strings, and images, students can also test every step as they design their programs. For the past three years, the curriculum has been deployed at eight middle schools (ages 10–14). The students tend to embrace programming enthusiastically after a nine-week course. Moreover, this kind of programming experience seems to improve the performance of these students in standard mathematics courses. The purpose of our paper is to share our technical development so that others can duplicate our pedagogical experiences, whether the functional I/O library is layered on top of an imperative library (as in our implementation) or on top of an I/O monad (as an implementation for Haskell would be). The next section provides an overview of our experiences to explain some of the sociological context where we apply functional programming with I/O. Section 3 illustrates how we start middle school students on functional programming. The rest of the paper focuses on the technical foundations.Sections 4 and 5 explain the I/O library and how to use it. Section 6 is about the college-level curricular context. In section 7, we compare our approach to the Clean Event I/O system, which is closest to our framework, and to some other pieces of work.
Functional programming languages ought to play a central role in mathematics education for middle schools (age range: 10–14). After all, functional programming is a form of algebra and programming is a creative activity about problem solving. Introducing it into mathematics courses would make pre-algebra course come alive. If input and output were invisible, students could implement fun simulations, animations, and even interactive and distributed games all while using nothing more than plain mathematics. We have implemented this vision with a simple framework for purely functional I/O. Using this framework, students design, implement, and test plain mathematical functions over numbers, booleans, string, and images. Then the framework wires them up to devices and performs all the translation from external information to internal data (and vice versa)—just like every other operating system. Once middle school students are hooked on this form of programming, our curriculum provides a smooth path for them from pre-algebra to freshman courses in college on object-oriented design and theorem proving. Categories and Subject Descriptors D.2.10 [Software Engineering]: Design—methodologies; D.4.7 [Operating Systems]: Organization and Design—interactive systems General Terms Design, Languages Keywords Introductory Programming
1. Functions for Freshmen Based on our decade-long experience (Felleisen et al. 2004a), novices to programming tend to accept languages that they haven’t heard of—as long as they can quickly construct a program that is like the applications they use on their computers. To this end, the chosen language must come with a rich framework for input and output (I/O), ideally via graphical interfaces. Chakravarty and Keller (2004) present corroborating evidence based on a thorough analysis of their Haskell-based introductory courses. They also report, however, that most Haskell texts deemphasize I/O. Our own review shows that three (Thompson 1997; Bird and Wadler 1998; Hutton 2007) of four major Haskell-based text books introduce I/O in the last third of the book or the final chapter; only Hudak (2000) tackles it head-on, though in a quasi-imperative manner.
2. Experience Age group
Since
Framework
middle school 2006 animation two- or three-object games, simplistic sound high school 2004 animation N object games, simulations college 2003 animation N object games, simulations, visualization college & MS program 2008 dist. prog. distributed two-player games
∗ This
research was partially supported by several grants from the National Science Foundation.
Schools eight ≥ 30 ≥ 20 Chicago & NEU
Variants of our I/O library have been in use since 2003 at a variety of places. The nearby table provides a concise overview of the kinds of students, sites, and projects that it enables. Clearly, the library is primarily useful in college-level freshman courses. Starting in 2004, we also introduced the library into the TeachScheme! workshops; some 30 schools have run courses with it. Three years ago, Emmanuel Schanzer derived the Bootstrap curriculum (www.bootstrapworld.org) from HtDP for Citizen Schools (www.citizenschools.org), which runs after-school programs
Permission to make digital or hard copies of all or part of this work for personal or classroom use is granted without fee provided that copies are not made or distributed for profit or commercial advantage and that copies bear this notice and the full citation on the first page. To copy otherwise, to republish, to post on servers or to redistribute to lists, requires prior specific permission and/or a fee. ICFP’09, August 31–September 2, 2009, Edinburgh, Scotland, UK. c 2009 ACM 978-1-60558-332-7/09/08. . . $5.00 Copyright
47
on a wide range of topics in poor neighborhoods across the US. At this point, eight sites (in Austin, the Bay Area, Boston and surroundings, and New York City) have used Bootstrap with a specialized variant of the I/O library. Finally, the first author uses the library in an immersion course for Northeastern’s MS program. Middle school students in the Bootstrap after-school courses typically have little background in mathematics. They are barely comfortable with calculations on numbers; they struggle with variables, if they have encountered them at all; and many are discouraged about the entire topic. Occasionally some of the students are concurrently enrolled in a first pre-algebra course. One goal of Bootstrap is to bring across the notion of a function as something that relates quantities, though not necessarily numbers. Naturally, the citizen teachers (volunteer instructors working for Citizen Schools) do not tell students that they are about to study a parenthesized form of algebra. Instead, they demonstrate interactive computer games and encourage students to think about creating such games on their own. A normal Citizen Schools course lasts nine weeks, with one two-hour session per week and no homework assignments. Within this time, most students design and systematically implement an interactive game of their choice that involves a fixed number of moving objects, object collisions, and score keeping. They code these games in the “Beginning Student” language of HtDP, extended with our new library. Citizen teachers report that the majority of students actively and enthusiastically participate in these courses, many asking for more when the course ends.1,2 By the end of the course, some of the citizen teachers share with the students that their game programs are mathematics. Anecdotal evidence suggests that making mathematics come alive in this manner has a direct impact on students’ performance in subsequent mathematics courses. Numerous instructors report conversations with mathematics teachers on how the students have changed their attitude about mathematics and how their grades have improved. The I/O library plays a similarly important role in high school courses and first-year college curricula as it does for Bootstrap. Many high school teachers and college instructors have noticed that students fail to understand mathematical functions. An introductory curriculum that makes functions critical and fun for animations, simulations, and interactive and multi-player/multi-computer games can thus play a central role in enhancing students’ preparation. Once students understand the basic idea of a function, it is easy to motivate them to study the systematic design of functions as advertised in HtDP (Felleisen et al. 2004b). After all, designing properly enables them to write more interesting simulations, animations, games, etc. As we explain in section 6, this kind of first course also prepares students well for the rest of the first year, including applicative and imperative object-oriented programming. Some typical examples are the freshman courses at the University of Chicago and Northeastern University. Although Chicago uses a quarter system, its course reaches the same milestones as Northeastern’s, due to the small class size at Chicago and students’ strong academic preparation. In both courses, students work out at least two complete game project in a purely functional setting,
often the same ones at both places (2008: Snake, Chat Noir). The first project (between 500 and 1,000 lines) is repeated twice, once to ensure students can implement suggestions and a second time to introduce abstraction via higher-order functions. The second project (some 1,000–2,000 loc) is typically an end-of-semester/quarter project where students have some creative freedom, too. The authors have repeatedly observed that students continue to work on their game programs after the semester/quarter ends.
3. Arithmetic, Algebra, and Movies In middle school, mathematics teachers often ask students to determine the next number in a sequence such as 1, 4, 9. Or they show students a series of shapes and ask what the next shape should look like. Eventually these series are arranged in the form of tables, e.g., x= y=
0 0
1 1
2 4
3 9
4 ?
... ...
i y(i)
and students are asked to fill in the result for 4 and to determine a general formula for determining any element in the sequence. Next comes an algebra course where students experience the joys of such exciting problems as two trains leaving from Chicago and Philadelphia and colliding in Pittsburgh. Functional programmers know that these students are encountering their first core concept in their mathematical education: functions and “variable expressions.” They also know that functions and expressions don’t need to be restricted to numbers and operations on numbers. It is perfectly acceptable to speak of the arithmetic and algebra of booleans, chars, strings, etc. DrScheme programmers also formulate functions and expressions that compute with images as first-class values, e.g., (empty-scene 100 100) returns a blank square of 100 by 100 pixels, and (place-image
10 20 (empty-scene 100 100))
combines the image of the rocket with the blank square by placing the former 10 pixels to the right of the left margin and 20 pixels down from the top margin of the latter. Now imagine teaching in this context. Teachers can ask students what the next image in the following series is: x= z=
0
1
2
3
4
? and what the general formula is for the image. As before, students would struggle and eventually come up with an answer. At this point, teachers could explain that displaying 25 to 30 of these scenes per second would create the effect of a “movie” that simulates a rocket landing. Students know movies, and students find movies more interesting than trains colliding in Pittsburgh. So the teacher could show them how to play this movie in DrScheme:
1 Due
to the demand, Schanzer [personal communication, Dec. 2008] is working on a second-level course for next summer. 2 Over the past few years, Alice [alice.org] and Scratch [scratch.mit. edu] have been touted as frameworks for teaching middle school students how to program. Both efforts are incomparable with Bootstrap for two reasons. First, both Alice and Scratch are mostly imperative and thus fail to have direct benefits for the students’ mathematics education. Second, as others also note (Powers et al. 2007), these GUI-oriented systems come without a natural transition to full-fledged programming whereas our curriculum spells out a natural path from middle school mathematics to the second semester in college.
(define (rocket-scene i) (place-image
50 i (empty-scene 100 100)))
The function definition captures the general answer to the teacher’s question, though in parentheical syntax. Although this isn’t close to the historically grown infix notation of mathematics (or to that of fashionable languages), our experience shows that students don’t seem to mind after some initial reluctance. If the student now
48
applies the library function run-simulation to rocket-scene, the library generates a series of scenes. More precisely, it applies its argument (here: rocket-scene) to 0, 1, 2, 3, . . . and displays the resulting series of images in a separate window, like the one embedded in this paragraph, at the rate of 28 numbers per second. The rest of the paper shows how to generalize this idea so that the language of 9th grade school mathematics can be used to design interactive, and even distributed, games.
event handlers that process these states. A world program matches these two parameters with a data definition for the collection of states and with a collection of functions. Figure 1 expresses this dependency between the library and a student’s program via a unit diagram (Flatt and Felleisen 1998). The universe library is parameterized over the type WorldSt; it exports two types and a function that consumes WorldSt-processing functions. Conversely, a world program is a unit that imports all of this, exporting in return a WorldSt type. Linking the two creates the executable. In reality, though, programs specify types as comments, and UNIVERSE does not export a function for specifying event handlers but a syntactic extension, dubbed big-bang: (big-bang WorldState-expr (on-tick tock-expr rate-expr† )† (on-key react-expr)† (on-mouse click-expr)† (stop-when done-expr)† (on-draw render-expr width-expr† height-expr† )† )
4. Designing a World The HtDP curriculum heavily emphasizes functional programming. DrScheme (Findler et al. 2002), HtDP’s accompanying IDE, supports a series of five teaching languages, each expanding the expressive power of its predecessor. The first four of these teaching languages are purely functional, and they are usually the only ones used in courses for novice programmers.
A big-bang expression has one required sub-expression—the initial state of the world—and five optional clauses (indicated via † superscripts). These clauses are introduced via one of five keywords (on-tick, on-key, on-mouse, stop-when, and on-draw), mimicking keyword-based parameter passing. Each clause specifies at least one sub-expression; two have additional optional subexpressions (see † superscripts). When PLT Scheme encounters a big-bang expression, it first evaluates all sub-expressions and checks some basic properties. The result of WorldState-expr becomes the initial state of the world. The remaining values give access to a subset of the underlying platform’s events:
Universe type WorldSt ... body ... type KeyEvt = ... type MouseEvt = ... val big-bang: WorldSt x (WorldSt -> WorldSt) x (WorldSt KeyEvt -> WorldSt) x (WorldSt Nat Nat MouseEvt -> WorldSt) -> WorldSt
1. If an on-tick clause exists, big-bang starts a clock that ticks at a rate of 28 times per second or as often as the result of rate-expr—a natural number—specifies. The expression tock-expr must evaluate to a function of one argument:
WorldProgram type KeyEvt; type MouseEvt; val big-bang: WorldSt x (WorldSt -> WorldSt) x (WorldSt KeyEvt -> WorldSt) x (WorldSt Nat Nat MouseEvt -> WorldSt) -> WorldSt
;; WorldSt → WorldSt Specifically, the function consumes a state of the world and produces one. The universe library invokes it on the current state every time the clock ticks; its result becomes the next state.
... body ...
2. An on-key clause specifies how a world program reacts to a keyboard event. Its sub-expression must evaluate to a function of two arguments:
type WorldSt
Figure 1. A unit perspective of world programs and UNIVERSE
;; WorldSt KeyEvt → WorldSt
Our I/O framework comes as a library, dubbed UNIVERSE . The library implements and exports two expression forms for launching world and universe programs. This section explains worlds; the next one is about universes.
The first is again the current state of the world; the second is a data representation of the keyboard event. In UNIVERSE , a keyboard event is represented as either a onecharacter string (e.g., "a") or a number of special strings (e.g., "left", "release"). The former denote regular keys on the keyboard; the latter are used to represent arrow keys, other special keys, and the event of releasing a key. The library invokes this function for every keyboard event and uses the result of the invocation as the new state of the world.
4.1 The World is a Virtual Machine To a student, the UNIVERSE library represents the computer’s operating system and hardware. As such the library is the keeper of a representation of the state of the world. When the hardware or operating system notices certain events, the library hands over the state of the world to a function in the student’s program and expects another state back. We call this state a world, and the phrase world program denotes the collection of functions that interact with the library. Combining the library and a world program creates an executable program. The library is parameterized over the kinds of states—called WorldSt—that the world program wishes to deal with as well as the
3. Similarly, an on-mouse clause determines how a world program reacts to a mouse event with a function of four arguments: ;; WorldSt Nat Nat MouseEvt → WorldSt As always, the first argument is the current state of the world. The next two arguments capture the x and y coordinates of the
49
What the figure does not show is the orthogonally specified rendering of each state as a scene or image. Although these images are values in PLT Scheme, they are usually not a component of world states. One way to imagine this rendering process is to add a different kind of arrow to each state and connecting this arrow to the scene that the on-draw function produces for this state. Given this explanation, we can explain the workings of the run-simulation function. Its world is the world of natural numbers, i.e., the state of the world represents the number of times the clock has ticked so far:
W′
W
tickH
tickH
keyH mouseH
keyH
W′′
mouseH
mouseH
tickH
...
...
keyH
W′′′′
;; WorldSt = Nat ;; interp. the number of clock ticks
W′′′
As for run-simulation, it consumes a function from natural numbers to Scenes. Its purpose is to start the world, to count the number of clock ticks, and to invoke the given function on each clock tick to render a series of Scenes:
Figure 2. A state transition diagram for world programs event, measured in the number of pixels from the left and top of the screen. Finally, the MouseEvt argument determines what kind of mouse action has taken place. It is one of the following six strings: "button-up", "button-down", "drag", "move", "enter", "leave". Like the underlying operating system, the UNIVERSE library does not notify a world program of every mouse event, but it samples the mouse events at a reasonably high rate. The result of applying the mouse-event handler function becomes the next world.
;; (Nat → Scene) → Nat (define (run-simulation render) (big-bang 0 (on-tick add1) (on-draw render))) The result of run-simulation is a natural number: specifically, the number of clock ticks that have passed (once the simulation halts). 4.2 Designing a World Program Designing a world program is surprisingly easy. The first step is to design a data representation for the information that varies and that is to be tracked through the duration of the program execution. We recommend expressing the data representation as a data (type) definition (or several) and equipping it with comments that interpret this data in terms of the visible canvas (world). Naturally, this data definition fills in for the WorldSt type from the preceding section. The second step is to tease out constants that describe properties of the world. This includes both quasi-physical constants, e.g., the width and height of the screen, as well as image constants, e.g., the background or a fixed shape that moves across the scenery. The third step is to design the event-handling functions. Here “design” refers to the design recipe from HtDP. Given that we already have data definitions (from the first step and the library), we also have contracts for all the top-level functions. Hence the next step is to think through examples and to turn them into tests. The creation of templates usually (but not always) uses the WorldSt type for orientation. After coding, it is important to run the tests. Also following HtDP, iterative development is the most appropriate approach for world programs. Specifically, we recommend that students provide a minimally useful data definition for WorldSt and then design one state-processing event handler and the rendering function. This enables them to test the core of the program and interact with it. From here, they can pursue two different directions: enriching the data and adding event handlers.
4. The stop-when clause determines when the world ends. Its sub-expression must evaluate to a predicate: ;; WorldSt → Boolean After handling an event with one of the above event-handling functions, UNIVERSE uses this predicate to find out whether the resulting state of the world is a final state. If the result state satisfies the predicate, no further events are processed. 5. Last but not least, a big-bang expression may come with an on-draw clause, which has either one or three sub-expressions. The first sub-expression must evaluate to a function of one argument: ;; WorldSt → Image If the big-bang expression specifies such a function, the UNI VERSE library opens a separate window whose size is determined by the size of the first image that the function produces. Alternatively, a program may specify the size of the canvas explicitly via the two additional sub-expressions, which must evaluate to natural numbers. The function specified in on-draw is used every time an eventhandling function produces a state. The resulting image is rendered in the separate window.
4.3 Controlling a UFO Let us illustrate how to design world programs with an example from the second or third week in a college freshman course. The goal of the exercise is to move a UFO (“flying saucer”) across the canvas in a continuous manner. Later we add functions that allow “players” to control the UFO’s movements via the arrow keys on the keyboard and via mouse clicks. A moving object on a flat canvas has (at least) four properties, meaning we need to use a structure4 to represent the essential data:
Once the world ends, big-bang returns the final state.3 As figure 2 suggests, the core of an executable world program denotes a state machine. Each element W, W , W , . . . of WorldSt is a state of this machine. For each state and for each kind of event, the event handlers (plus event-specific inputs) specify the successor state; that is, each state—except for final ones—is the source of three (family of) arrows (with distinct targets). The final states are those for which the predicate specified in the stop-when clause produces true.
4 In
teaching languages, a structure definition like this one introduces three kinds of functions: a constructor (make-ufo), a predicate (ufo?), and one selector per field to extract the values (ufo-x, ufo-y, ufo-dx, ufo-dy). PLT Scheme also adds imperative mutators on demand.
3 It
is instructive to contrast this to the type of reactimate in Fran (Elliot and Hudak 1997).
50
;; WorldSt KeyEvt → WorldSt ;; control the ufo’s direction via the arrow keys (check-expect (control (make-ufo 5 8 -1 -1) "down") (make-ufo 5 8 -1 +1)) ;; ... more test cases ... (define (control w ke) (cond [(key=? ke "up") (set-ufo-dy w -1)] [(key=? ke "down") (set-ufo-dy w +1)] [(key=? ke "left") (set-ufo-dx w -1)] [(key=? ke "right") (set-ufo-dx w +1)] [else w])) ;; WorldSt Int → WorldSt (define (set-ufo-dy u dy) (make-ufo (ufo-x u) (ufo-y u) (ufo-dx u) dy))
;; WorldSt Nat Nat MouseEvt → WorldSt ;; move the ufo to a new position on the canvas (check-expect (hyper (make-ufo 10 20 -1 +1) 40 30 "button-up") (make-ufo 10 20 -1 +1)) ;; ... more test cases ... (define (hyper w x y a) (cond [(mouse=? "button-down" a) (make-ufo x y (ufo-dx w) (ufo-dy w))] [else w])) ;; WorldSt → Boolean ;; has the ufo landed? (check-expect (landed? (make-ufo 5 (- SIZE 5) -1 +1)) false) ;; ... more test cases ... (define (landed? w) (>= (ufo-y w) SIZE))
Figure 3. Using keyboard and mouse events to control a ufo (define-struct ufo (x y dx dy)) ;; WorldSt = (make-ufo Nat Nat Int Int) ;; interp. the location (pixels) ;; and velocity (pixels/tick)
Before we can interact with the program, we must design one more function, namely, a function for rendering the current state of the world as a scene: ;; WorldSt → Scene ;; place the ufo into MT at its current position
Because nothing else in this “game” changes over time, we identify the state of the world with the state of the UFO. Next we fix the size of the canvas, the background (an empty scene), and the shape of the UFO: (define SIZE 400) (define MT (empty-scene SIZE SIZE)) (define UFO (overlay (circle 10 "solid" "green") (rectangle 40 2 "solid" "green"))) ) (define UFO.version2
(check-expect (render (make-ufo 10 20 -1 +1)) (place-image UFO 10 20 MT)) (define (render w) (place-image UFO (ufo-x w) (ufo-y w) MT)) Designing such a function proceeds according to the same recipe as designing the move function. Also notice that we can test the outcome of this function as if it were a function on the reals. Because images are first-class values, it makes sense to construct the expected output and to compare it to the actual result of the function. PLT Scheme’s standard equal? function works for images, too. While we recommend that students develop such “expected results” expression (interactively in the REPL) to gain some understanding of how the function should proceed, it is indeed possible to insert an actual image instead of such an expression:
This time we use basic image creation and manipulation primitives to create the right kind of shape; using the definition of UFO.version2 instead of UFO would of course work equally well. With the above data definition, we have determined the complete type signature of the event-handling functions for clock tick events. Of course we should add a purpose statement: ;; WorldSt → WorldSt ;; move the ufo for one tick of the clock
(check-expect (render (make-ufo 10 20 -1 +1)) ) Equipped with move and render, it is possible to define a main function and to watch these first two definitions in action: ;; WorldSt → WorldSt ;; run a complete world program, ;; starting in state w0 (define (main w0) (big-bang w0 (on-tick move) (on-draw render)))
The next step in our design recipe calls for examples that describe the behavior of the function. We formulate these examples immediately in the unit testing framework that comes with DrScheme’s teaching languages:5 (check-expect (move (make-ufo 10 20 -1 +1)) (make-ufo 9 21 -1 +1)) The example illustrates that the function’s purpose is to add the velocity to the current position and to use it as the new position: (define (move w) (make-ufo (+ (ufo-x w) (ufo-dx w)) (+ (ufo-y w) (ufo-dy w)) (ufo-dx w) (ufo-dy w)))
In short, we have finished the first stage of our iterative design cycle, creating a first useful part of the overall program. From here, it is easy to design the rest of the function. See the left-hand side of figure 3 for the definition of a function that controls the movements of the UFO via arrow keys. The function key=? compares two keyboard events. The right-hand side of the same figure displays functions for making the UFO jump to the position of a mouse click; mouse=? of course compares mouse events. The last function checks whether the UFO has landed.
5 DrScheme
collects all check-expect expressions and evaluates them after all definitions and expressions are evaluated. It then outputs the results and tabulates failed test cases with hyper-links to the source text of the test.
51
5. Universe: A World is Not Enough
W*
Designing interactive graphical programs via purely functional programming is only half the game. The other half is about designing distributed programs, especially distributed games. The principles remain the same, but the differences deserve a close look.
S-exp
tickH S-exp
recH
5.1 Universes
S-exp
A universe consists of a distributed collection of world programs that communicate with each other via a programmable server:
W′
tickH
tickH
keyH mouseH recH
keyH mouseH recH
S-exp
Universe
W World 2
keyH S-exp W′′
S-exp S-exp
S-exp S-exp
mouseH S-exp...
S-exp S-exp
...
...
World 3
keyH tickH
mouseH
S-exp S-exp
World 1
Server
World 4
We make no assumption about where the programs run, in particular, UNIVERSE cannot find servers automatically. The communication links rely on TCP/IP connections, meaning messages sent from a world to a server (or vice versa) are guaranteed to arrive in the order in which they are dispatched. Of course, when two distinct world programs send messages to the server, there is no guarantee that the messages arrive in the order they were sent; similarly, if the server broadcasts messages to (some of) the participating worlds, the messages may again arrive at distinct worlds in an unrelated order. In order to design a universe based on the UNIVERSE teachpack, students design a communication protocol, which they implement via a “server” program. Some protocols simply pass messages from one world program to another and back, with the server playing the role of a conduit. Other protocols assume that the server is an arbiter, enforcing the rules of a game or directing traffic among the participants, as in a chat room. Finally, the server could be configured in such a way that the world programs simulate peers in a peer-to-peer neighborhood.
S-exp
W′′′′
W′′′
Figure 4. State transition view of world communicating programs Figure 4 is a revision of figure 2 for communicating worlds. Again, all elements of WorldSt are states, but now all states come with four kinds of transition arrows. The fourth one is the event handler that deals with message receipts. In addition, each arrow now comes with an optional output label in the form of an Sexpression. Just as UNIVERSE displays the rendering of a state as an image for a world program, it also implements the sending these messages from state transitions to the universe’s server. 5.3 The Universe and its Server The UNIVERSE library supports the design of servers in a manner that is analogous to the design of world programs. A programmer describes a server via a pair of specifications: a data definition of universe states, dubbed UniSt, and a universe description, which is analogous to a big-bang description. For a server, three kinds of events matter most: the entry of an additional world into the universe, a world’s disappearance, and the arrival of a message from a participating world. Accordingly server programs must deal with representations of participating worlds, and UNIVERSE supports this:
5.2 A World in the Universe For a world program to participate in a universe, it registers with the server using a (register ip-expr) clause in its big-bang expression. The sub-expression designates an IP address (as a string). A registered world program sends messages via its event handlers. To this end, the UNIVERSE library defines package structures and exports its constructor and predicate: (define-struct package (world msg)) ;; Package = (make-package World S-exp)
(define-struct iworld (name in out)) ;; IWld = (make-iworld String Port Port) ;; interp. internal representation ;; of a participating world The iworld structure keeps track of a world program’s name, its input TCP port, and its output port, though a server program may only access the name field of iworld structures. Other than that, server programs must compare worlds and do so with iworld=?. Here is the core grammar of a universe description:
Moreover, the library actually deals with event handlers that return one of two kinds of results, meaning the signature of, say, key event handlers is really ;; WorldSt KeyEvt → (∪ Package WorldSt)
(universe UniSt-expr (on-new new-expr) (on-msg msg-expr) (on-disconnect disc-expr disc-expr)† ...)
instead of the one specified in the preceding section. If an event handler produces a package, the library uses the value in the first field as the next state of the world, and the value in the second field is sent off to the server. Otherwise, the library assumes the result is just the state of the world. To receive messages, a world program installs an event-handling function via an on-receive clause in big-bang. It subexpression must evaluate to a function with the following signature:
The first, required sub-expression determines the initial state of the server. Furthermore, every universe description comes with an on-new clause and an on-msg clause. Optionally, it may also contain an on-disconnect clause. Every server’s event handler consumes the current state of the universe—as perceived and maintained by the server’s event handlers—and the representatin of a participating world; it may also consume a message received from such a world. An event
;; WorldSt S-exp → (∪ Package WorldSt) When a message in the form of an S-expression arrives, this event handler is applied to the current state of the world and the message. Like all other event handlers, this handler may return a Package.
52
handler produces a bundle, i.e., a UNIVERSE -specified structure that contains three distinct pieces of information: the new server state (UniSt); a list of messages to designated worlds; and the list of worlds to be discarded: (define-struct bundle (state mails to-discard)) (define-struct mail (to msg)) ;; Bundle = (make-bundle UniSt Mail∗ IWld∗) ;; Mail∗ = [Listof (make-mail IWld S-exp)] ;; IWld∗ = [Listof IWld]
Our experimentation with the UNIVERSE library suggests that interaction diagrams—like those used for object-oriented designs based on UML—are a good medium for discussing ideas. Instead of spelling out this recommendation in detail, however, we illustrate it with a simple example. 5.5 Serving a Turn As mentioned, the coordination among the worlds of a universe depends on the server and the message protocol it employs. We and our students have implemented a number of servers. Here we illustrate the power of the UNIVERSE library with the design of a server and some UFO controller clients where each client gets a turn to control a (local) UFO. We start with the protocol design, followed by the design of the server, and then the adaptation of the UFO program from section 4 to support distribution.
Event handlers may only construct bundles and mails; they may not destructure them. The event handlers function as follows: 1. An on-new handler has the signature ;; UniSt IWld → Bundle
Protocol Design The prose suggests the following, informal and schematic interaction diagram:
i.e., it consumes the server state and a representation of the world that wishes to join. The resulting bundle may contain this new world as one that should be discarded, which effectively represents a rejection of the request. Optionally, the handler may send out messages about the event.
server world1: sam register: "sam"
2. An optional on-disconnect event handler has the same signature as an on-new handler, but it deals with the disappearance of a world from the universe:
"your-turn"
;; UniSt IWld → Bundle This kind of event is usually due to a severed connection or because the corresponding world program shut down.
3. The signature for on-msg handlers also includes the message that arrived in the form of an S-expression:
-
world2: carl register: "carl"
"done" "your-turn"
-
;; UniSt IWld S-exp → Bundle When the on-msg event handler is invoked, it is applied to the state of the server, the world that sent in a message, and the message itself. The result bundle determines how this event is shared with other worlds in the universe.
"done" "your-turn"
Optional handlers may drive the server via clock ticks, render the current state of the server in a console, or deal with other events. A complete universe program—as specified in a universe expression—is best thought of as a state-transition machine, just like the one for world programs depicted in figure 4. Each element of UniSt is a state of the machine; each event handler (and its auxiliary parameters) represents one possible transition from one UniSt element to another. In contrast to world programs, the state transitions in a universe program come with two labels: one for sending mail to a list of participating worlds, and another one for deleting worlds from the list of participants.
-
The three vertical lines are “world life-lines,” while the horizontal lines are registration or message sending steps. This particular diagram shows the key properties of our proposed universe. The server is on the left; the participating worlds are to its right. After creation, a world registers with the server, which we assume sends along a name for the world. Our diagram shows that as soon as a first world has registered, the server gives this world a turn without waiting for any other world to show up. If another world shows up—possibly during some turn—the server becomes aware of it but continues to wait for a "done" signal from the world whose turn it is. Once the active world ends its turn, the server gives a turn to the next world on the list. Finally, the diagram also shows what happens when a world disappears, say due to the closure of a connection. The server notes the disappearance and gives a turn to (one of) the remaining worlds.
5.4 Designing a Universe Designing a universe requires two different perspectives: a global one concerning coordination and local ones for the server and the world programs. Once the global view has been developed, the local design of the servers and world programs proceeds just like stand-alone world programs. The global perspective demands the design of a coordination and communication protocol. This protocol design has the goal of creating and maintaining an invariant for the universe. In order to achieve this goal, we teach students to consider the start-up phase, the steady-state phase, and the shut-down phase of a universe. For all cases, it is important to understand (1) the order in which events occur and (2) which S-expressions encode which messages.
Server Design From here, the design of the server proceeds just like the design of a world program, though we must observe the constraints imposed by the protocol. We start with the required data definition: ;; UniSt = ;; interp. ;; ;;
53
IWld∗ list of worlds in the order they take turns, starting with the active one the active world (if any) is first
;; UniSt IWld → Bundle ;; nw is joining the universe (check-expect (add-world (list iworld2) iworld1) (make-bundle (list iworld2 iworld1) ’() ’())) ;; ... more test cases ... (define (add-world ust nw) (if (empty? ust) (make-bundle (list nw) (m2 nw) ’()) (make-bundle (append ust (list nw)) ’() ’()))) ;; UniSt IWld "done" → Bundle ;; mw sent message m; assume mw = (first ust), m = "done" (check-expect (switch (list iworld1 iworld2) iworld1 "done") (make-bundle (list iworld2 iworld1) (m2 iworld2) ’())) ;; ... more test cases ... (define (switch ust mw m) (local ((define l (append (rest ust) (list mw))) (define nxt (first l))) (make-bundle l (m2 nxt) ’())))
;; UniSt IWld → Bundle ;; dw disconnected from the universe (check-expect (del-world (list iworld1 iworld3) iworld3) (make-bundle (list iworld1) ’() ’())) ;; ... more test cases ... (define (del-world ust dw) (if (not (iworld=? (first ust) dw)) (make-bundle (remq dw ust) ’() ’()) (local ((define l (rest ust))) (if (empty? l) (make-bundle ’() ’() ’()) (local ((define nxt (first l)) (define mll (m2 nxt))) (make-bundle l mll ’())))))) ;; IWld → Mail∗ ;; create single-item list of mail to w ;; no test cases (define (m2 w) (list (make-mail w "your-turn")))
Figure 5. A primitive functional server program (universe ’() (on-new add-world) (on-msg switch) (on-disconnect del-world))
Note again interpretation that comes with the data definition. It has several implications for the design of the event handlers. Since this server deals with three kinds of events—registration of a world, message receipt, and disconnection of a world from the universe—we need three event handlers. The UNIVERSE specifications and the agreement to send certain messages dictate the contract statements:
Adding this expression to the bottom launches a process that waits for TCP/IP events and deals with them by invoking one of the three event handlers.
;; add-world : UniSt IWld → Bundle ;; switch : UniSt IWld "done" → Bundle ;; del-world : UniSt IWld → Bundle
Client Design To illustrate how the client side works, let us consider a small change to our UFO controller from the preceding section. Suppose we give each “player” a turn to land a UFO and that when the UFO touches the ground, it is the next world’s turn. One obvious implication is that there is now a distinct new kind of state of the world: ;; WorldSt is one of: ;; --- "rest" ;; --- (make-ufo Nat Nat Int Int)
The names of the three functions are suggestive of their purpose. Just as in the case of the UFO controller, we can design these functions in a systematic manner. In support of unit tests for event handlers in a server, UNIVERSE exports three sample worlds iworld1, iworld2, and iworld3; of course, it does not export the capability of creating representations of participating worlds. Otherwise, the design of these three server functions proceeds in a straightforward fashion. The three definitions and fragments of their test suites are displayed in figure 5:6
When it isn’t this world’s turn, the world is in a "rest" state. Next we replace the event handler for ticks with a function that sends out messages when the UFO lands: ;; WorldSt → (∪ WorldSt Package) (define (move.global w) (cond [(string? w) w] [else (local ((define v (move w))) (if (not (landed? v)) v (make-package "rest" "done")))]))
1. the top-left box contains the code for adding a world; 2. the box in the bottom-left defines the function for dealing with a message from the active world, which is the only kind of messages that the server expects; 3. the top-right box concerns the event of a world disconnecting from the universe; and 4. the final box in the bottom right contains the definition of a auxiliary function for creating a list of mail to a single world.
The function distinguishes the two cases from the data definition. For a string, it returns the world as is. Otherwise, it moves the world using the old move function and then checks whether the UFO has landed; if so, the new event handler produces a package. In addition, we need a handler for "your-turn" messages:
As far as the server is concerned, the only task left to do is to formulate the universe expression and to evaluate it at DrScheme’s reply to start the server:
;; WorldSt "your-turn" → WorldSt ;; assume: messages arrive only ;; if the state is "rest" (define (receive w msg) (make-ufo 20 10 -1 +1))
6 The
definitions use the local construct from the HtDP teaching languages. Roughly speaking, (local defs body) introduces the mutually recursive definitions defs for the evaluation of body. Unlike Scheme’s internal definitions, local definitions have the exact same semantics as global definitions but come with a restricted lexical scope.
54
;; WorldSt (WorldSt → WorldSt) (WorldSt KeyEvt → WorldSt) (WorldSt Nat Nat MouseEvt → WorldSt) [Listof Event] ;; → ;; WorldSt ;; process a list of events given the initial world and event handlers (define (big-bangF w0 tickH keyH mouseH loe0) (local (... dispatch: see below, on the right ... ;; accumulator design: w is the result of dealing with all events between loe0 and loe (inclusive) (define (big-bangF w loe) (cond [(empty? loe) w] [else (big-bangF (dispatch w (first loe)) (rest loe))]))) (big-bangF w0 loe0))) (define-struct tick ()) (define-struct key (kind)) (define-struct mouse (x y kind)) ;; An Event is one of: ;; --- (make-tick) ;; --- (make-key KeyEvt) ;; --- (make-mouse Nat Nat MouseEvt)
;; WorldSt Event → WorldSt ;; deal with a single event, given the state of the world (define (dispatch w e) (cond [(tick? e) (tickH w)] [(key? e) (keyH w (key-kind e))] [(mouse? e) (mouseH w (mouse-x e) (mouse-y e) (mouse-kind e))]))
Figure 6. The semantics of functional event handling Unlike move.global, receive does not distinguish two kinds of worlds. Whether the world is in a resting state or not, the function returns some UFO. The revised main function registers the world with the server and specifies a name for the world that is used for registration:
the third and fourth part of the book (and its teaching languages) lambda and local definitions are added. Programming is developed as the systematic design of computational solutions to “word” problems. The design of individual functions follows a general six-step procedure paired with a systematic development of data definitions. The design of programs is presented as an iterative refinement process, comparable to the scientific process of developing models of the world. Specifically the program is the model, and the world is the set of our (or our client’s) objectives. As we refine the program, our model satisfies more and more of the objectives. Obviously, this design recipe also applies to the design of I/O functions for world and universe programs. The key is that UNI VERSE translates external information into internal data and invokes the event handlers on the latter. Furthermore, the event handlers produce only internal data, which UNIVERSE then displays as external information. The translations are hidden from the students’ transformations. Hence, the process of formulating contracts, functional examples, etc. remains the same. Because images are just another form of atomic data, the design recipe even applies to the rendering functions that produce complex graphical scenes. The separation of the actual act of performing I/O from the processing or production of I/O data is critical for effective testing. It empowers a programmer to unit-test every single function, covering the complete chain from where input data appears to the point of where output data is delivered. As a matter of fact, this covers the testing of image-producing functions for which we recommend two different testing strategies. The first is to develop an expression in the read-eval-print loop of DrScheme that creates an image for simple inputs. This kind of experimentation suggests both an “expected value” expression as well as the body for the desired function. The second strategy is to create the expected image separately:
;; String → WorldSt (define (main-for-client n) (big-bang "rest" (on-tick move) (on-draw renderR) (on-rec receive) (name n) (register LOCALHOST))) Here we assume the server is running on the same computer as the client and that renderR renders the new kind of worlds. Note: The design assumes that all participating worlds and the server implement the protocol correctly. The assumptions above suggest how functions may protect themselves against errors in the implementations or attacks. The reader may wish to explore the small changes needed to check those assumptions.
6. Design and Curriculum Designing reactive programs in a purely functional manner comes with several advantages. For one, it is straightforward to explain big-bang as if it were a function. As figure 6 shows, this function traverses a list of events,7 accumulating the changes to the initial world. Also, it uniquely fits in with our design curriculum, which covers functional design followed by courses on logical reasoning and object-oriented design. 6.1 Design Recipe HtDP introduces its teaching programming languages as a generalization of school mathematics. Instead of functions over just numbers, these languages can express functions and expressions that deal with atomic data (numbers, symbols, chars, strings, images, boolean data) and compound data (structures, vectors, and lists). In
(check-expect (create-ufo) ) (check-expect (render-world (make-ufo ...)) (place-image ... ... (empty-scene SIZE SIZE)) As the second check-expect specification shows, it is of course possible to mix and match those two strategies. Once tests are developed, DrScheme’s built-in test coverage tool pin-points those expressions that haven’t been evaluated during a
7 Our
implementation replaces the list with an imperative stream of events, plus a thread for receiving messages from the server. The stream dispatcher and the thread are coordinated via the CML-inspired synchronization primitives of PLT Scheme.
55
(define world% (class fun-world% (super-new) (init-field ufo) (field [MT (empty-scene 500 500)]) ;; → world% ;; deal with a tick event in this world (define/augment (tick) (new world% [ufo (send ufo move/tick)]) ) ;; → scene ;; render this world as a scene (define/augment (render) (send ufo render MT))))
(define world% (class imp-world% (super-new) (init-field ufo) (field [MT (empty-scene 500 500)]) ;; → void ;; deal with a tick event in this world (define/augment (tick) (send ufo move/tick) ) ;; → scene ;; render this world as a scene (define/augment (render) (send ufo render MT))))
(define ufo% (class object% (super-new) (init-field x y dx dy) (field [UFO (overlay (rectangle ...) (circle ...))]) ;; → ufo% ;; move this ufo for one tick (define/public (move/tick) (new ufo% [x (+ x dx)][y (+ y dy)][dx dx][dy dy]) ) ;; → scene ;; add this ufo to the given scene s (define/public (render s) (place-image UFO x y s))))
(define ufo% (class object% (super-new) (init-field x y dx dy) (field [UFO (overlay (rectangle ...) (circle ...))]) ;; → void ;; effect: change this ufo’s coordinates, for a move (define/public (move/tick) (begin (set! x (+ x dx)) (set! y (+ y dy))) ) ;; → scene ;; add this ufo to the given scene s (define/public (render s) (place-image UFO x y s))))
Figure 7. Applicative and imperative world classes test run. We want novice programmers to attempt to cover all expressions, except for those that connect the event handlers to the underlying operating system (big-bang, universe). While complete coverage is a good first goal, the design of reactive programs tends to demonstrate that unit testing does not suffice. Even when an individual reactive function passes all unit tests, the composition of all the reactive functions to deal with a large stream of events often concocts scenarios that the unit tests don’t cover. Put differently, reactive programming demands some amount of integration testing, too. Given our “list of events” semantics, programmers can usually mimic these scenarios with the composition of event handlers. Last but not least, because the event handlers are just functions, we can also subject them to the functional random testing (Claessen and Hughes 2000) tools now built into DrScheme or its theorem proving environment (Eastlund 2009). Indeed, programmers who learn to formulate conjectures and validate conjectures via random testing are ideally prepared to study the automated verification of interactive/reactive programs.
The mechanized proofs are based on the semantics of the big-bang function in figure 6 and a more general version for universes of world programs. Specifically, a macro unfolds claims about a specific instance of big-bang expressions into an application of a function like big-bangF to all possible lists of events. 6.3 On to Classes At the same time as freshmen learn to formulate claims about their functional animation programs and to prove them correct, they are enrolled in a parallel course on design in the context of class-based object-oriented languages. We prepare the transition at the end of the first semester with some simple conventions and arrangements. Specifically, instead of arranging functions by feature (e.g., all rendering functions in one place, all key-event related functions somewhere else), we organize functions around data definitions. For example, we start with all event handlers for WorldSt: ;; WorldSt is one of ... ;; WorldSt → WorldSt (define (world-tickh w) ...)
6.2 Reasoning about Worlds and Universes During their second semester at Northeastern University, computer science majors study the logic of computation. The course combines a standard theoretical introduction into logic with practical hands-on exercises based on the ACL2 system (Boyer and Moore 1996); see our experience report on the test run of this course (Eastlund et al. 2007). Roughly speaking, the ACL2 system consists of an applicative Common Lisp and an automatic theorem prover based on first-order classical logic. Two years ago we extended the ACL2 system with the UNI VERSE library, enabling students to write reactive games, formulate conjectures about the safety of their game programs, and prove them correct via the ACL2 theorem prover (Eastlund and Felleisen 2009). Here is a typical theorem from such experiments:
;; WorldSt → Scene (define (world-render w) ...) ;; WorldSt KeyEvt → WorldSt (define (world-keyh w ke) ...) and follow it up with an arrangement around UFO: ;; UFO is one of ... ;; UFO → UFO (define (ufo-move u) ...)
(defthm preserve-safety (implies (safe-state game-state) (safe-state (tick game-state)))
;; UFO Scene → Scene (define (ufo-add-to-scene u s) ...) ;; UFO Symbol → UFO (define (ufo-chg u dir) ...)
When the theorem prover fails, students are encouraged to subject their conjectures to our ACL2 random tester (Eastlund 2009).
56
We always make the current state the first parameter of a function, analogous to the implicit this parameter in methods. An experienced programmer can immediately see that programming functional I/O methods is notationally even more convenient in a class-based context than in a functional language. In contrast to functions, methods are defined in a context where all the pieces of a world are accessible as fields. Consider the left-hand side of figure 7. It displays a version of the UFO program in PLT Scheme’s class system (Flatt et al. 2006).8 The functions from section 4 have been turned into methods of a class world% and ufo%. Each event-handling method returns a new instance of the class. Instead of selectors, the methods use field names to access the current values of the world state. Furthermore, the world% class is derived from an abstract class that provides default functionality for all event handlers and the imperative functionality for connecting event-handling methods to the machine’s devices. It naturally motivates inheritance and overriding. Finally, while an applicative world design with classes is notationally superior to a structure-based design, it still suffers from the notational overhead of creating new objects for every transformation. The move/tick (“move per tick”) method in ufo%, for instance, copies both the dx and the dy field into the new instance. Compare this method with move/tick in the imperative variant of ufo% on the right-hand side of figure 7. In general, the transition from a state-transforming functional program to an imperative object-oriented program is straightforward, easy to explain, and thus clarifies to students how the design principles of their first, functional experience carries over to the languages they expect to encounter in college.
concrete types (images). This concreteness enables UNIVERSE programmers to test all functions of an interactive graphical program, including those that produce output. Contrast this situation with the use of an abstract device type in Clean and of the I/O monad in Haskell. The testing of I/O functions in such a framework is similar to the testing of imperative procedures, requiring elaborate set-up and tear-down steps. We consider this activity out of reach for middle school students and distracting for courses that focus on design. Functional reactive programming (FRP) (Elliot and Hudak 1997) overcomes this problem by enabling programmers to write in a functional style over imperative values (event streams, behaviors). The programmer effectively describes a dataflow graph via expression dependencies; the run-time system updates values using this graph. While programming with event streams and behaviors is truly elegant, our pedagogic experience has been that the necessity of operators like switch puts it out of the reach of novices. Technically, FRP also has the disadvantage of requiring devices to be adapted to behave as reactive elements, which is a research problem that has been solved only partially (Ignatoff et al. 2006). Erlang (Armstrong et al. 1996) factors its I/O framework in a different but related manner. A distributed program in Erlang also consists of world-transforming event handlers, though such a program also need a process-local loop to keep track of the state. Our UNIVERSE library naturally separates these two concerns by factoring out the common loop from the server and the participants. From a pedagogical perspective, van Dam and his colleagues (1987, 1995) pioneered the event-oriented approach for teaching novices in the 1980s, but via imperative object-oriented programming. Bruce et al. (2001, 2004) resumed this direction in the early 2000s. We consider the functional alternative presented here even more useful than an imperative, object-oriented approach. On one hand, a functional approach is close to the mathematics that students encounter, meaning our approach promises a straightforward skill transfer. While we have only anecdotal evidence so far, we are convinced that a formal evaluation would confirm this conjecture. On the other hand, we consider object-oriented programming for novices an overkill because beginners don’t have programs of enough complexity to benefit from the structuring that object-orientation provides and demands. Chakravarty and Keller (2004) share our analysis concerning the teaching of functional programming languages in the first course as well as the problems of Haskell I/O. Their reaction is to turn this weakness of Haskell into an advantage. Specifically, the course switches perspective, emphasizing the imperative character of I/O actions and the need for ordering actions. While we acknowledge the pedagogical need for a transition to imperative programming, we consider this strategy a kludge and prefer the systematic approach via objects explained in section 6.3. After all, postponing I/O suggests that functional programming can’t cope with the full spectrum of programming tasks and fails to exploit it for the motivational aspects of assignments. An alternative and appealing solution is due to Achten (2008), who packaged up one special-purpose case study (playing soccer) along the lines of our framework. Sadly focusing on soccer limits the appeal of the framework to certain cultures and countries. Finally, Hudak and Peterson each briefly taught Haskell-based functional programming to small groups of selective middle school and high school students. Both arranged lectures around Haskore and Pan but did not use any texts [Hudak and Peterson, independent personal communication, Feb. 2009.]
7. Related Work From a technical perspective, the Clean Event I/O system (Achten and Plasmeijer 1995) comes closest to our approach.9 The Clean programming language supports so-called abstract I/O devices to which programs attach event handlers. In contrast to our event handlers, a Clean event handler has the following signature ;; WorldSt × ∗DeviceSt → WorldSt × ∗DeviceSt where DeviceSt type represents the state of an abstract I/O device. The ∗ notation on a type adds a linearity constraint on the type; the type system enforces this linearity constraints for the matching function parameter. For event handlers, the linearity constraint means that reading and writing to the I/O devices is enabled and translated into efficient imperative actions. Naturally, linearity constraint also has implications for the design and organization of event handlers, making them look like imperative functions. Our I/O framework supports only devices (windows, keyboards, mouse clicks, clocks) whose state can be supplied all at once when an event handler is invoked. Conversely, if a state needs to change, the event handlers don’t write to the device. Instead, the library uses an orthogonal rendering function to translate the state into an image that it displays, or it allows event handlers to return an additional value that it writes to a TCP port. In short, because our framework completely decouples event processing from writing to a device, there is no need in our framework to use linearity types and to thread the state of a device through an event handler. An additional difference between Clean and UNIVERSE concerns the nature of the devices. In Clean I/O devices are abstract types; in UNIVERSE the rendering functions translate states into 8 In
our courses and workshops, we use Java. (with Weirich, 2000) turned the Clean Event I/O system into the Clean Object I/O system and later ported it to Haskell (Achten and Jones 2001). Daan Leijen provided a binding to the wx media kit, now known as the wxHaskell toolkit [Achten, personal communication, Feb. 2009].
8. Conclusion
9 Acten
Our work demonstrates that with a suitable I/O framework, purely functional programming is an engaging medium for students of all ages. The Bootstrap effort routinely guides middle school students
57
without apparent mathematical talent to write interactive games in a language that is basically equivalent to high school algebra. For freshman students, we exploit the same framework to simultaneously strengthen their mathematical skills and to introduce them to the basics of program design. In one second-semester course, students even use an automatic theorem prover to establish interesting properties about such interactive games. At the same time, eventdriven programming can also be used to prepare freshmen for a course on object-oriented programming. Our work relies on two key insights and one technicality. First, it is important to leave the translation of external information into internal data (structures) to the framework and vice versa. As far as students are concerned, these are tasks that the computer and/or the operating systems takes on for the program. Second, the framework must separate event handling (as state transitions) from rendering (from states to images, sounds, or message transmission). This separation of concerns empowers novice programmers to design one function per task, without worrying about ordering any computational actions. One DrScheme-specific technicality facilitates the second step: turning images into first-class values. Although inserting images into programs and dealing with them directly at an interactive read-eval-print can be especially helpful, we don’t expect this technicality to be critical for an adaptation of our approach to other functional languages. In short, we conjecture that every functional language can easily supplement its I/O system with a library such as ours and could thus become an appealing medium for a range of educational applications.
Kim B. Bruce, Andrea Danyluk, and Thomas P. Murtagh. Event-driven programming is simple enough for cs1. SIGCSE Bull., 33(3):1–4, 2001. Kim B. Bruce, Andrea Danyluk, and Thomas P. Murtagh. Event-driven programming facilitates learning standard programming concepts. In Object-oriented programming systems, languages, and applications: Educators Symposium, pages 96–100, 2004. Manuel Chakravarty and Gabriele Keller. The risks and benefits of teaching purely functional programming in first year. J. Funct. Program., 14(1): 113–123, 2004. Koen Claessen and John Hughes. QuickCheck: a lightweight tool for random testing of Haskell programs. In ACM SIGPLAN International Conference on Functional Programming, pages 268–279, 2000. Carl Eastlund. DoubleCheck your theorems. In Proc. 8th Intern. Works. ACL2 and its Applications, pages 41–46. Lulu Press, 2009. Carl Eastlund and Matthias Felleisen. Automatic verification for interactive graphical programs. In Proc. 8th Intern. Works. ACL2 and its Applications, pages 33–41. Lulu Press, 2009. Carl Eastlund, Dale Vaillancourt, and Matthias Felleisen. ACL2 for freshmen: First experiences. In Proc. 7th Intern. ACL2 Symposium, pages 200–211. ACM Press, 2007. Conal Elliot and Paul Hudak. Functional reactive animation. In ACM SIGPLAN International Conference on Functional Programming, pages 196–203, 1997. Matthias Felleisen, Robert Bruce Findler, Matthew Flatt, and Shriram Krishnamurthi. How to Design Programs. MIT Press, 2001. URL http://www.htdp.org/. Matthias Felleisen, Robert Bruce Findler, Matthew Flatt, and Shriram Krishnamurthi. The TeachScheme! project: Computing and programming for every student. Computer Science Education, 14:55–77, 2004a.
Acknowledgments We gratefully acknowledge the help of many people: Carl Eastlund for feedback on the design and for discussions concerning its logical content; Kathi Fisler for using experimental releases of the library in her courses; Emmanuel Schanzer for creating and coordinating the Bootstrap outreach program; and Danny Yoo for extending the library with hierarchical GUI features.
Matthias Felleisen, Robert Bruce Findler, Matthew Flatt, and Shriram Krishnamurthi. The structure and interpretation of the computer science curriculum. J. Funct. Program., 14(4):365–378, 2004b. Robert Findler, John Clements, Cormac Flanagan, Matthew Flatt, Shriram Krishnamurthi, Paul Steckler, and Matthias Felleisen. DrScheme: A programming environment for Scheme. J. Funct. Program., 12(2):159– 182, March 2002.
References Peter Achten. Teaching functional programming with soccer-fun. In Proc. 2008 International Workshop on Functional and Declarative Programming in Education, pages 61–72, 2008. Peter Achten and Simon L. Peyton Jones. Porting the Clean object I/O library to Haskell. In IFL ’00: Selected Papers from the 12th International Workshop on Implementation of Functional Languages, pages 194–213, London, UK, 2001. Springer-Verlag. Peter Achten and Marinus J. Plasmeijer. The ins and outs of Clean I/O. J. Funct. Program., 5(1):81–110, 1995. Peter Achten and Martin Wierich. A tutorial to the Clean Object I/O library (version 1.2). Technical report, University of Nijmegen, February 2000. Joe Armstrong, Robert Virding, Claes Wikstr¨om, and Mike Williams. Concurrent Programming in Erlang (2nd Edition). Prentice-Hall, 1996. Bird and Wadler. Introduction to Functional Programming (2nd Edition). Prentice Hall PTR, 1998. Robert S. Boyer and J Strother Moore. Mechanized reasoning about programs and computing machines. In R. Veroff, editor, Automated Reasoning and Its Applications: Essays in Honor of Larry Wos, pages 146–176. The MIT Press, Cambridge, Massachusetts, 1996. URL citeseer.ist.psu.edu/boyer96mechanized.html.
Matthew Flatt and Matthias Felleisen. Units: Cool modules for HOT languages. In ACM SIGPLAN Conference on Programming Language Design and Implementation, pages 236–248, June 1998. Matthew Flatt, Robert Bruce Findler, and Matthias Felleisen. Scheme with classes, mixins, and traits. In Asian Symposium on Programming Languages and Systems (APLAS) 2006, pages 270–289, November 2006. Paul Hudak. The Haskell School of Expression: Learning Functional Programming through Multimedia. Cambridge Univ. Press, 2000. Graham Hutton. Programming in Haskell. Cambridge Univ. Press, 2007. Daniel Ignatoff, Gregory H. Cooper, and Shriram Krishnamurthi. Crossing state lines: Adapting object-oriented frameworks to functional reactive languages. In International Symposium on Functional and Logic Programming, pages 259–276, 2006. Bryan O’Sullivan, Donald Stewart, and John Goerzen. Real World Haskell. O’Reilly Media, Inc., 2008. Kris Powers, Stacey Ecott, and Leanne Hirshfield. Through the looking glass: teaching CS0 with Alice. SIGCSE Bulletin, 39(1):213–217, 2007. Simon Thompson. Haskell: the Craft of Functional Programming. Addison Wesley Longman Publishing Co., Inc., 1997.
58
Experience Report: Embedded, Parallel Computer-Vision with a Functional DSL Ryan R. Newton
Teresa Ko
MIT CSAIL, Cambridge, MA, USA
[email protected]
UCLA Vision Lab, Los Angeles, CA, USA
[email protected]
Abstract This paper presents our experience using a domain-specific functional language, WaveScript, to build embedded sensing applications used in scientific research. We focus on a recent computervision application for detecting birds in their natural environment. The application was ported from a prototype in C++. In reimplementing the application, we gained a much cleaner factoring of its functionality (through higher-order functions and better interfaces to libraries) and a near-linear parallel speed-up with no additional effort. These benefits are offset by one substantial downside: the lack of familiarity with the language of the original vision researchers, who understandably tried to use the language in the familiar way they use C++ and thus ran into various problems.
Figure 1. Example background subtraction results.
the WaveScript implementation, but rather the software engineering impact of specific language features on the implementation of our application, namely: (1) multi-stage programming; (2) higherorder functions; (3) parametric and ad-hoc polymorphism; and (4) shared-nothing message-passing parallelism. But first we need to describe the application itself.
Categories and Subject Descriptors: D.3.2 Concurrent, distributed, and parallel languages; Applicative (functional) languages; Data-flow languages General Terms: Design, Languages, Performance Keywords: stream processing languages, computer vision
1.
2.
James Reserve Vision Application
A number of pertinent questions about the impact of climate change on our ecosystem are most readily answered by visually monitoring fine-scale interactions between animals, plants, and their environment. For example, species distribution, feeding habits, and timing of plant blooming events are often best observed through visual sensing. Some quantities, such as CO2 intake of plants, have no ”in the wild” sensor and can only be captured through visual sensing. In this paper, we focus on detecting birds at a feeder station in the wild with a network of cameras. Bird populations are particularly informative about changes in the ecosystem, as species distributions can quickly change due to their mobility. The camera infrastructure used is part of the James Reserve Wildlife Observatory. A feeder station was constructed and equipped with a webcam and server. It captures a frame a second at 704x480 pixels. There is inherent pressure to increase a camera’s coverage at the cost of reducing the size of the objects of interest in the image, thereby creating a more challenging detection and recognition task. Similarly, increasing temporal coverage (battery lifetime) pushes for lower sampling rates, limiting the applicable methods. The resulting image sequence will inevitably have small birds with little features to distinguish them from one another or from the background, and instances of the same bird being in a completely different location in consecutive frames. Our vision system is able to identify instances of a single bird in spite of these challenges. The system consists of two major components: background subtraction and bird classification. The case study in this paper will focus on the background subtraction component, because it is both computationally intensive and a substantial improvement over the state of the art in this domain.
Introduction
A sensor network deployment typically involves a collaboration between domain experts and computer scientists (though the latter would ideally be optional). The domain experts are often programmers themselves, often building prototypes in Matlab or C++. The expertise in short supply is in embedded software development. Therefore, tools that make it easier to transition from prototypes to embedded code are of great value. Functional programming is not known for its use in embedded programming, to say the least. But following the recent trend in two-stage domain-specific languages (DSLs) (3; 7; 5), we find that a stream-processing DSL can retain the software engineering benefits of functional programming (in the metaprogram), while generating good embedded code, exploiting parallelism, and partitioning programs transparently across embedded devices and more powerful “servers”. In this paper we describe our experience using the WaveScript language to implement the latest version of our computer vision application for sensor networks. This paper is not about
Permission to make digital or hard copies of all or part of this work for personal or classroom use is granted without fee provided that copies are not made or distributed for profit or commercial advantage and that copies bear this notice and the full citation on the first page. To copy otherwise, to republish, to post on servers or to redistribute to lists, requires prior specific permission and/or a fee. ICFP’09, August 31–September 2, 2009, Edinburgh, Scotland, UK. c 2009 ACM 978-1-60558-332-7/09/08. . . $5.00 Copyright
59
2.1
700
Background on Background
Natural environments such as the forest canopy present an extreme challenge to background subtraction because the foreground objects, by necessity, blend with the background, and the background itself changes due to the motion of the foliage and the rapid transition between light and shadow. For instance, images of birds at a feeder station exhibit a larger per-pixel variance due to changes in the background than due to the presence of a bird. Rapid background adaptation fails because birds, when present, are often moving less than the background and often end up being incorporated into it. Our background subtraction approach is based on building a model of the colors seen in the neighborhood around each pixel and then computing the difference between each new pixel value and the historical model for that pixel’s neighborhood. Therefore, the algorithm must maintain state for each pixel in the image (its model) and traverse each input image, comparing each new pixel against the model, and updating the model based on the values of surrounding pixels. An example result can be seen in Figure 1. The background model for the pixel located at the ith row and jth column is in general a non-parametric density estimate, denoted by pij (x). The feature vector, x ∈ R3 , is a colorspace representation of the pixel value. For computational reasons, we consider the simplest estimate, given by the histogram 1 X δ(s − x), (1) pij (x) = |S| s∈S
Lines of code
500
0 Original
Refactored
PixelLevel
Figure 2. Background Subtraction: Lines of code. Each work function is an imperative routine that processes a single stream element, updates the private state for that dataflow operator, and produces elements on output streams. The job of the WaveScript front-end is to partially evaluate the source (meta) program to create the dataflow graph, whereas the WaveScript backend performs graph optimizations, profiles and partitions graphs across devices (6), and reduces work functions to an intermediate language that can be fed to a number of backend code generators. The final intermediate language for work functions is a monomorphic first-order language that is easily retargetable to any platform that has a C-compiler (and many that don’t). WaveScript currently supports many embedded platforms including TinyOS “motes”, smartphones running JavaME, iPhones, and embedded Linux devices such as routers. WaveScript is used for embedded sensing applications that involve digital signal processing together with more irregular event processing. For example it has been used for acoustic localization of wild animals (2) and detection of potholes with sensor-equipped taxicabs (4). WaveScript itself is essentially an ML-dialect with a C-like syntax, a special form for accessing first-class streams, and miscellaneous extensions (e.g., extensible records). A top-level source program returns a stream value. Timers and drivers for hardware sensors provide stream sources, and a pair of primitives are the sole means of processing streams: merge combines streams in real-time order of arrival, and iterate is a “for-each” style construct whose evaluation creates a new dataflow operator and provides its work function, and whose return value is a new stream. The user manual contains details (1).
(2)
τ
where Sτ , the set of pixel values contributing to the estimate is defined as (4)
The Bhattacharyya distance between qij,τ (x) and the corresponding background model distribution for that location, pij,τ −1 (x), calculated from the previous frames, is computed to determine the foreground/background labeling. The Bhattacharyya distance between two distributions is given by Z p pij,τ −1 (x)qij,τ (x)dx, (5) d=
4.
Implementation in WaveScript
The application was ported to WaveScript from a prototype in C++. Figure 2 shows a breakdown of how lines of code were spent in both the C++ and WaveScript versions of the application. The porting process consisted of four steps:
X
1. Port code verbatim to WaveScript.
where X is the range of valid x’s and d ranges from 0 to 1. Larger values imply greater similarity in the distribution. A threshold on the computed distance, d, is used to distinguish between foreground and background. While subtle, this combination of background model and classifier allows for large articulated movements in the background to be ignored and small foreground objects to be detected.
3.
300
100
where xt (a, b) is the colorspace representation of the pixel at the ath row and bth column of the image taken at time t. The feature vector, x, is quantized to better approximate the true density. To detect foreground at time τ , a distribution, qij,τ (x), is similarly computed for the pixel located in the ith row and jth column using only the image at time τ according to 1 X δ(s − x), (3) qij,τ (x) = |Sτ | s∈S
Sτ = {xτ (a, b) | |a − i| < C, |b − j| < C},
400
200
where S, the set of pixel values contributing to the estimate, is defined as S = {xt (a, b) | |a − i| < C, |b − j| < C, 0 ≤ t < T },
Data Acquisition Core Algorithm Setup
600
2. Factor duplicated code using higher-order functions. 3. Remove unnecessary floating point. 4. Parameterize design; expose parallelism. The most interesting step is exposing parallelism. The algorithm is clearly data parallel. In fact, a separate process can compute each pixel’s Bhattacharyya distance (and update each pixel’s model) independently. But the data-access pattern is non-trivial. To update each pixel’s model, each process must read an entire patch of pixels from the image around it. Thus, tiling the matrix and assigning tiles to worker threads is complicated by the fact that such tiles must
WaveScript, Briefly
A WaveScript program constructs a dataflow graph of stream operators that executes in a non-synchronous (event-driven) manner. Each operator consists of a work function and optional private state.
60
800 600 400 200 0
d
nt
dP
xe
Fi ne
te
at
Fl
ed or
ct Fa
tim
al
a rb Ve
in
Execution time in milleseconds for one frame
1000
Variant of background subtraction algorithm
Figure 4. Single threaded performance of ported versions vs. original C++ version. Shows average time for processing each frame on a 3.2 gHz Xeon machine. populateBg is called repeatedly with a series of Images to ready
the background model before processing (classifying) new frames. In this version of the code, no significant type abstraction has yet been applied. The “model” for each pixel is a three-dimensional histogram in color space (the argument to populateBg is four dimensional to include a 3D histogram for each pixel in the image). The background subtraction algorithm as a whole consists of 1300 lines of code containing three functions very much like populateBg. A second function updates the model for each new frame, and a third compares each new image with the existing model to compute Bhattacharyya distances for each pixel. These functions traverse both the input image and stored histograms. The performance of the original version, initial port, and subsequent refactorings is illustrated in Figure 4. Generally speaking, excluding automatic memory management, the object-language generated by WaveScript shares most of the characteristics of C code.
Figure 3. An excerpt from the verbatim port.
overlap so each pixel may reach its neighbors. For these reasons, it is not straightforward to implement this algorithm in most stream processing languages. For example, stream processing languages tend to require that the input to each stream operator be a linear sequence of data. Exposing parallelism and exploiting locality in the background subtraction stage then requires an appropriate serialization of the matrix (for example, using Morton-order matrices), but this in turn creates complicated indexing. It is reasonable to say that the stream-processing paradigm is not a natural fit for parallel matrix computations. Yet it can be made to work using a high-level streaming language with a full datatypes (algebraic datatypes, dynamic allocation) and the additional power of meta-programming. 4.1
1200
rig O
f o r r = 0 t o rows−1 { / / c r e a t e t h e l e f t most p i x e l ’ s h i s t o g r a m from s c r a t c h c : : Int = 0; roEnd = r − o f f s e t + S i z e P a t c h ; / / end o f p a t c h coEnd = c − o f f s e t + S i z e P a t c h ; / / end o f p a t c h f o r r o = r−o f f s e t t o roEnd−1 { / / c o v e r t h e row r o i = i f r o < 0 t h e n −ro −1 e l s e i f r o >= rows t h e n 2 ∗ rows−1−r o e l s e r o ; f o r co = c−o f f s e t t o coEnd−1 { / / c o v e r t h e c o l c o i = i f co < 0 t h e n −co−1 e l s e i f co >= c o l s t h e n 2 ∗ c o l s −1−co e l s e co ; / / get the pixel location i = ( roi ∗ cols + coi ) ∗ 3; / / f i g u r e out which h i s t o g r a m bin : binB = ( I n t ) ( ( F l o a t ) image [ i ] ∗ i n v s i z e B i n s 1 ) ; binG = ( I n t ) ( ( F l o a t ) image [ i + 1 ] ∗ i n v s i z e B i n s 2 ) ; binR = ( I n t ) ( ( F l o a t ) image [ i + 2 ] ∗ i n v s i z e B i n s 3 ) ; / / add t o t e m p o r a r y h i s t o g r a m t e m p H i s t [ binB ] [ binG ] [ binR ] += s a m p l e W e i g h t ; } }; / / c o p y temp h i s t o g r a m t o l e f t m o s t p a t c h f o r cb = 0 t o NumBins1−1 { f o r cg = 0 t o NumBins2−1 { f o r c r = 0 t o NumBins3−1 { b g H i s t [ k ] [ cb ] [ cg ] [ c r ] += t e m p H i s t [ cb ] [ cg ] [ c r ] ; }}}; / / increment pixel index k += 1 ; / / c o m p u t e t h e t o p row o f h i s t o g r a m s f o r c = 1 t o c o l s −1 { ... / / Here : two more r o / co l o o p s l i k e a b o v e . / / T h e s e add and s u b t r a c t new d a t a f r o m t h e / / histogram to update i t i n c r e m e n t a l l y . ... }}
4.2
Refactoring Code
The next step was to simply clean up the code. Some of this consisted of factoring out simple first-order functions to capture repeated index calculations (a refactoring applied just as easily to the original C++). Other refactorings involved using higher order functions, for example, to encapsulate traversals over the image matrices and thereby remove for-loops and indexing expressions. After refactoring, the code was reduced to 400 lines. The clearer structure of the populateBg function can be seen in Figure 5. Both Figures 3 and 5 contain an optimization: the difference in histograms for neighboring pixels is small, and one can incrementally be computed from the other: adding some samples, removing others, and thereby “sliding” the patch. But this optimization has become much clearer in the structure of Figure 5. In this version we have begun to abstract the types used. Rather than a 4D nested array to represent a 5D space (a matrix of histograms), we use the preexisting WaveScript 2D and 3D matrix libraries. These provide ADTs with multiple implementations, including a WaveScript native one and a Gnu Scientific Library wrapper (which uses BLAS). Not needing linear algebra, we use the former in this paper. Swapping in a flattened row-major representation results in fewer small objects and fewer pointer dereferences, creating the performance improvement shown in Figure 4. (Note that the initial “Factoring” damaged performance, possibly by obscuring backend compiler optimizations.) Also, it’s a banal point, but parametric polymorphism for data types is critical. Most likely, the inconvenience of emulating this in C++ (templates) and the lack of
Porting Verbatim
Because WaveScript has imperative constructs and a C-like concrete syntax, it is straightforward to do a verbatim translation of C or C++ code. This does not in any way extract parallelism (it results in a dataflow graph with one operator). But it is the best way to establish correspondence with the output of the original program and then proceed by correctness preserving refactorings. The original source code possessed substantial code duplication (Figure 3), having mainly to do with repeated processing of nested arrays, including index calculations. The code in Figure 3 is part of the populateBg function, which builds the initial background model for each pixel and takes as input storage space for the models and an input image. It has the following signature: populateBg :: (Array4D Float, Image) -> (); type Image = (RawImage * Int * Int); // With wid,height type RawImage = Array Color; type Array4D t = Array (Array (Array (Array t)));
61
t y p e P i x e l H i s t = Matrix3D F l o a t ; p o p u l a t e B g : : ( M a t r i x P i x e l H i s t , Image ) −> ( ) ; fun p o p u l a t e B g ( b g H i s t , ( image , c o l s , rows ) ) { / / bgHist : background histograms / / image : frame of video stream a s s e r t e q ( ” Image s i z e : ” , l e n g t h ( image ) , rows∗ c o l s ∗ 3 ) ; t e m p H i s t = P i x e l H i s t : make ( rows , c o l s ) ; / / Strong assumption about order of matrix t r a v e r s a l : M a t r i x : f o r e a c h i ( b g H i s t , rows , c o l s , fun ( r , c , b g H i s t r c ) { i f c ==0 t h e n i n i t P a t c h ( r , c , rows , c o l s , t e m p H i s t , image ) e l s e s h i f t P a t c h ( r , c , rows , c o l s , t e m p H i s t , image ) ; / / c o p y temp h i s t o g r a m t o l e f t m o s t p a t c h : Matrix3D : m a p i n p l a c e 2 ( b g H i s t r c , t e m p H i s t , ( + ) ) ; }) }
Figure 5. The populateBg function builds background models (histograms) for the “Patch” centered around each pixel. First it creates a histogram for the leftmost pixel in a row. Then the next pixel’s histogram is calculated incrementally by shiftPatch: (1) removing pixels in the left most col of the previous patch from the histogram and (2) adding pixels in the right most col of the current pixel’s patch to the histogram. The foreachi function (as opposed to foreach) also passes indices for the data being accessed.
Figure 6. An example dataflow graph resulting from using a tile/pixel transform with rows = cols = 2 (generated by the compiler using AT&T GraphViz). Even though the pixels are partitioned into four disjoint tiles (dark purple), an extra margin must be included around each tile (orange) to ensure that each pixel within the tile may access its local neighborhood.
built-in matrix libraries resulted in the use of monomorphic, nested arrays in the original source. 4.3
x × y tiles, with an overlap of w pixels in both dimensions. First, the init function is called (at metaprogram evaluation) to initializes the mutable state for each worker. Then, at runtime, each tile is processed by the transf orm function, producing a new tile. The type signature for tagged tile kernel is listed in Figure 7. The “tagged” part is an additional complication introduced by the control-structure of the application. Because there is no shared state between kernels, all data must be passed through streams. Typical of stream-processing applications, there is a tension between dividing an application into finer-grained stream kernels and avoiding complicated data movement between kernels (for example, packing many configuration parameters into the stream)1 . In the case of the background subtraction algorithm it is desirable to pass an extra piece of information (tag) from the original stream of matrices down to each individual tile- or pixel-level worker, which is exactly what the interface tagged tile kernel allows. For the background subtraction application, an earlier phase of processing determines what mode the computation is in (populating initial background model, or estimating foreground) and attaches a mode flag (boolean) on the stream of images.
Reducing Floating Point
One of our goals in porting this application was to run it on a wide range of embedded hardware as well as on multicore desktops (and partitioned between the two). In particular, we used Nokia smartphones (N95) with ARM processors lacking floating-point units. Thus, the penultimate step was to reduce the use of floating point calculations (for example, in calculating indices into the color histograms), replacing them with integer or fixed-point calculations. This results in a significant speedup even on desktop machines (Figure 4). WaveScript offered no special support for this refactoring. While it has what amounts to a built-in Num type class, which helps write reusable code, ideally there would be some tool support for this common problem: porting to fixed point, monitoring overflows and quantifying the loss of accuracy. 4.4
Exposing Parallelism, Design Parameterization
Finally, the most interesting part of this case study was using WaveScript to parallelize the application. Fortunately, refactoring for clarity (abstracting data types, matrix transforms) had gotten us most of the way. The essential change was to move away from code handling monolithic matrices (arrays) to expressing the transform locally on image tiles—we use tile to refer to a fixed submatrix of the image—and then finally at the level of the individual pixel (with the stipulation that a pixel transform must also access its local neighborhood). The end result was a reusable library for parallel matrix computations (see parmatrix.ws). The first step is to implement transforms on tiles. From the perspective of the client code, this is the same as doing the transform on the original matrix (only smaller). The library code handles splitting matrices into (overlapping) tiles and disseminating those tiles to workers. The resulting dataflow graph structure is seen in Figure 6. The results of each independent worker are joined, combined into a single matrix, and passed downstream to the remaining computation. In Figure 7 we see the interface for building tile kernels via the function tagged tile kernel. Calling tagged tile kernel(x, y, w, transf orm, init) will construct a stream transformer that splits matrices on its input stream into
From tiles to pixels: The next step in building the parmatrix.ws library was to wrap the tile-level operator to expose only a pixel-level transform. The modified interface is shown in Figure 8. Note that the result—a stream transformer on matrices—has the same type as the tile-level version. The core of the background subtraction algorithm, using the pixel-level interface, is shown in Figure 9. There is, however, one problem. When processing image data at the tile level, it is still possible to incrementally update histograms within a tile, albeit with decreased benefit as tiles get smaller. The pixellevel version based on the interface in Figure 8, however, cannot leverage this optimization. We will return to this issue in a moment. In Figure 8, the Nbrhood type is used to represent the method for accessing the pixels in a local vicinity. It is a function mapping 1 This
is analogous to the sometimes awkward growth in the number of function arguments in purely functional programs, where function arguments are the sole means of communication between disparate program fragments. Of course this problem can be addressed by structuring techniques, for example, using a Reader monad.
62
tagged tile kernel :: / / F i r s t , p r o v i d e # X / Y w o r k e r s and d e p t h o f / / neighborhood a c c e s s (” o v e r l a p ”) r e q u i r e d : ( Int , Int , Int , / / Work f u n c t i o n a t e a c h t i l e : ( ( t a g , s t , T i l e px ) −> T i l e px2 ) , / / Per−t i l e s t a t e i n i t i a l i z e r : ( T i l e px ) −> s t ) −> S t r e a m ( t a g ∗ M a t r i x px ) −> S t r e a m ( M a t r i x px2 ) ;
:: tagged pixel kernel sliding nbrhood ( Int , Int , Int , / / Work f u n c t i o n t a k e s p i x e l s i n and p i x e l s o u t . / / ’ carry ’ w i l l r e p r e s e n t t h e l a s t computed r e s u l t : ( t a g , s t , c a r r y , P i x S e t px , P i x S e t px ) −> ( px2 , c a r r y ) , / / Per−p i x e l s t a t e i n i t i a l i z e r , t a k e s i n d i c e s : ( I n t , I n t ) −> s t , / / F u n c t i o n t o compute t h e f i r s t c a r r y : Nbrhood px −> c a r r y ) −> S t r e a m ( t a g ∗ M a t r i x px ) −> S t r e a m ( M a t r i x px2 ) ;
type Tile t = ( Matrix t ∗ ( I n t ∗ I n t ) ∗ ( I n t ∗ I n t ) ) ; t y p e P i x S e t t = ( ( I n t , I n t , t ) −> ( ) ) −> ( )
Figure 7. Signature for tile-level transform. This function creates X×Y workers, each of which handles a region of the input image. The transform is applied to each tile, and may maintain state between invocation, so long as that state is encapsulated in a mutable value passed as an argument to the transform. This assumes the overlap between tiles is the same in both dimensions. A tile is a piece of the original matrix together with metadata to tell where it came from; its fields are: (1) a matrix, (2) tile origin on original matrix, and (3) original image dimensions.
Figure 10. Signature for pixel-level transform supporting incremental computation of result. With PixSets we avoid constructing new sets in memory, rather we let the client ”visit” the relevant pixels (and indices). Whole-program function inlining removes any performance penalty with this pattern. on tagged tile kernel, and can only leverage incremental results within a tile.
5.
tagged pixel kernel with nbrhood :: ( Int , Int , Int , / / X, Y , overlap ( t a g , s t , Nbrhood px ) −> px2 , / / Per−p i x e l s t a t e i n i t i a l i z e r , t a k e s i n d i c e s : ( I n t , I n t ) −> s t ) −> S t r e a m ( t a g ∗ M a t r i x px ) −> S t r e a m ( M a t r i x px2 ) ;
WaveScript Learning Curve
The core background subtraction algorithm was ported by the first author, who is also the primary implementor of WaveScript, and naturally finds it easy to use. However, as other members of the UCLA group got involved and used WaveScript for other parts of the application, the retraining challenges became clear. There were two main lessons learned, having to do with C-like syntax and quotation-free metaprogramming. First, WaveScript’s syntactic similarity to C-family languages does reduce initial trepidation (even if perhaps it shouldn’t). Certainly, several domain specific languages (e.g., Bluespec (7)) have ended up mimicking C or Verilog syntax for this reason. But we found that it also encouraged attempts to directly reuse inappropriate programming idioms. Setting aside basic misunderstandings of WaveScript constructs (for example, one programmer, unfamiliar with type inference, thought type declarations were necessary for assigning types to variables rather than defining new types), the major problem we found was with the use of mutable state. Of course, many C programmers use mutation by habit. WaveScript’s support for mutable variables and arrays can encourage this. For example, programmers would often declare state globally and attempt to modify it within the work functions for stream operators:
t y p e Nbrhood a = ( I n t , I n t ) −> a ;
Figure 8. Signature for pixel-level transform.
tagged pixel kernel with nbrhood ( workersX , workersY , o v e r l a p , / / T h i s i s t h e work f u n c t i o n a t e a c h p i x e l . fun ( bgEstimateMode , b g h i s t , n b r h o o d ) { i f bgEstimateMode then p o p u l a t e P i x H i s t ( b g h i s t , nbrhood ) ; e l s e e s t i m a t e F g P i x ( b g h i s t , nbrhood ) ; }, / / I n i t i a l i z e per−p i x e l s t a t e ; c r e a t e a h i s t o g r a m : fun ( i , j ) Matrix3D : make ( NumBins1 , NumBins2 , NumBins3 , 0 ) )
Figure 9. Background subtraction using a pixel-level transform.
myvar = 0; S2 = iterate x in S1 { myvar++; ... }
(x, y) locations onto pixel values. At (0, 0) the function gives the value of the center pixel (the one being transformed and updated). With this we are able to express the background subtraction application as a simple pixel-level transformation. The core of the implementation is shown in Figure 9. Given a boolean tag on the input stream (bgEstimateMode), and a state argument that contains the histogram for just that pixel (bghist), the kernel decides whether to populate the background (populatePixHist) or to estimate the foreground (estimateFgPix).
Dangerously, this example actually works; the WaveScript evaluation model involves reifying a stream value back into code, and whatever state is found in the environment of the closure attached to a dataflow operator (work function) becomes the private state for that operator. However, sharing state between operators (the same mutable object reachable by two closures) is disallowed and will result in a compile-time error. The WaveScript design is predicated on the idea that understanding these meta-program evaluation failures is easier than understanding the error messages generated by a sufficiently sophisticated type system to rule out the errors. Nevertheless, it still helps to teach a “beginner” mode, where a special state keyword forces each operator’s state to be declared in a restricted lexical scope:
Restoring incremental histograms: The final step in porting our background subtraction algorithm was to reenable incremental computation of histograms in spite of the pixel-level interface to images. This is not difficult, but it does make the interface more complex (seen in Figure 10). Rather than direct access to neighboring pixel values, now the pixel kernel sees a sliding patch across the image (currently square, but could be generalized to a rectangle). The carried over result from the last position is used, together with the pixels sliding into view and the pixels sliding out, to compute the new result. The underlying implementation is still based
S2 = iterate x in S1 { state { myvar = 0 } myvar++; ... }
The second major hurdle was understanding meta-programming in WaveScript. Again, WaveScript assumes that a simple model with some exceptions and corner cases is easier to learn than a more sophisticated one. Specifically, unlike more general meta-
63
Speedup relative to single threaded version
expect to see an outlier at 16 cores where the mapping is one-to-one (and perhaps 8, 4, and 2). Allowing the metaprogram to read the number of CPUs and determine a tile size is an example of the utility of the metaprogram as a separate phase that precedes the WaveScript compiler’s own dataflow-graph scheduling and optimization. Still, it would be ideal to expose more of this tuning problem to the compiler itself. In the future, perhaps a method will be discovered to expose the compiler’s own profile-driven optimization and auto-tuning process so that the programmer may delegate the tile-size decision. This application also turned out to be a good candidate for distributed (inter-device) partitioning. After performing background subtraction most of the image is simply blacked out; with simple run-length encoding, these frames require much less network bandwidth when sent back to the server over a wireless network. Moreover, once the parmatrix.ws library is used, the space of choices becomes more continuous. Each tile-level worker can be placed on the embedded or server-side. Hosting half the workers results in half the data-reduction at half the cost, and, importantly, half the memory usage for expensive 3D histograms. For a detailed discussion of WaveScript’s partitioning methodology, see (6). Ultimately, while this application was greatly improved during its reimplementation, some of the interfaces we used represent a more imperative formulation than we would like—for example, kernels accepting a mutable state argument rather than producing a fresh state. A pure formulation would be ideal, but we are not currently able to achieve it with the near zero performance penalty that we require. Impure or not, abstracting control-flow was nevertheless valuable in this application.
12 10 8 6 4 2 0 0
2
4
6
8
10
12
14
16
Number of cores enabled
Figure 11. Parallel speedup: 16 data-parallel workers with variable number of enabled processor cores. Test platform was an AMD Barcelona with four quad-core processors. Cores were disabled using Linux’s /sys/devices/system/cpu interface. The data point at 0 shows the single threaded speed—i.e. the program configured with only a single worker. The drop in performance at data-point 1 is due to the overhead of splitting and reassembling the matrix.
programming languages like MetaML (8), WaveScript does not use explicit quotation and anti-quotation constructs for creating “code values”. Rather, the programmer is told that everything outside of an iterate will evaluate at compile-time. Nonetheless we saw frequent confusion about whether a data structure should be initialized at meta-program evaluation time, or at the beginning of runtime. Also, there is the issue of distinguishing the capabilities of the meta- and object-languages. Like many other two-stage DSLs, WaveScript is an asymmetric meta-programming system, where the meta-language differs from the object-language. (For example, the object-language lacks closures.) Attempting to use meta-language features in the object-language results in a compile-time error (with code location). For example, one programmer tried to read configuration files at runtime, which is not possible if the code is running on an embedded platform such as a phone. One related problem had to do with the foreign-function interface (FFI), which can only be accessed at runtime. Programmers ran into difficulties trying to use the FFI before they had a firm grasp of the language. Some of these uses of the FFI were spurious, while others were an unfortunate consequence of the need to interface with hardware to get off the ground in sensing applications. Possible fixes would include banning the FFI in the aforementioned beginner mode, or restricting its use to strict idioms for data acquisition.
6.
References [1] Wavescript users manual, http://regiment.us/wsman/. [2] Michael Allen, Lewis Girod, Ryan Newton, Samuel Madden, Daniel T. Blumstein, and Deborah Estrin. Voxnet: An interactive, rapidlydeployable acoustic monitoring platform. In IPSN ’08: Information processing in sensor networks, 2008. [3] Lennart Augustsson, Howard Mansell, and Ganesh Sittampalam. Paradise: a two-stage dsl embedded in haskell. ICFP Experience Report, pages 225–228, 2008. [4] Jakob Eriksson, Lewis Girod, Bret Hull, Ryan Newton, Samuel Madden, and Hari Balakrishnan. The pothole patrol: using a mobile sensor network for road surface monitoring. In MobiSys ’08: Proceeding of the 6th international conference on Mobile systems, applications, and services, pages 29–39, New York, NY, USA, 2008. ACM. [5] Geoffrey Mainland, Greg Morrisett, and Matt Welsh. Flask: staged functional programming for sensor networks. SIGPLAN Not., 43(9):335–346, 2008. [6] Ryan Newton, Sivan Toledo, Lewis Girod, Hari Balakrishnan, and Samuel Madden. Wishbone: Profile-based partitioning for sensornet applications. In NSDI’09: Networked Systems Design and Implementation, 2009. [7] R. Nikhil. Bluespec system verilog: efficient, correct rtl from high level specifications. Formal Methods and Models for Co-Design, 2004. MEMOCODE ’04., pages 69–70, June 2004. [8] W. Taha and T. Sheard. Multi-stage programming with explicit annotations. In Partial Evaluation and Semantics-Based Program Manipulation, Amsterdam, The Netherlands, June 1997, pages 203–217. New York: ACM, 1997.
Results and Discussion
The end result of this project was a cleaned up implementation that also exposes parallelism. (And a reusable parallel matrix library!) Parallel speedups for the final version of the background subtraction algorithm are shown in Figure 5. These results are generated given a single, fixed 4 × 4 tiling of the matrix (16 tiles) that results in 16 stream operators (“workers”) running on 16 threads. Another approach is to have the metaprogram set the tiling parameters based on the number of CPUs on the target machine. But this is complicated by the need to factor the target number of threads into separate x and y components such that xy = numthreads, which results in tiles of varying aspect ratios. Somewhat suprisingly, the operating system does a great job of juggling these 16 threads among a variable number of processor cores, with the exception of the unfortunate case at 11 cores. If the OS were doing poorly, we would
64
Runtime Support for Multicore Haskell Simon Marlow
Simon Peyton Jones
Satnam Singh
Microsoft Research Cambridge, U.K.
[email protected]
Microsoft Research Cambridge, U.K.
[email protected]
Microsoft Research Cambridge, U.K.
[email protected]
Abstract
communication, or synchronisation. They merely annotate subcomputations that might be evaluated in parallel, leaving the choice of whether to actually do so to the runtime system. These so-called sparks are created and scheduled dynamically, and their grain size varies widely. Our goal is that programmers should be able to take existing Haskell programs, and with a little high-level knowledge of how the program should parallelise, make some small modifications to the program using existing well-known techniques, and thereby achieve decent speedup on today’s parallel hardware. However, when we started benchmarking some existing Parallel Haskell programs, we found that many programs which at first glance appeared to be completely reasonable-looking parallel programs, in fact failed to achieve significant speedup when run with our implementation on parallel hardware. This led us to a critical study of our (reasonably mature) baseline implementation of semi-explicit parallelism in GHC 6.10. In this paper we report the results of that study, with the following contributions:
Purely functional programs should run well on parallel hardware because of the absence of side effects, but it has proved hard to realise this potential in practice. Plenty of papers describe promising ideas, but vastly fewer describe real implementations with good wall-clock performance. We describe just such an implementation, and quantitatively explore some of the complex design tradeoffs that make such implementations hard to build. Our measurements are necessarily detailed and specific, but they are reproducible, and we believe that they offer some general insights. Categories and Subject Descriptors D.3.2 [Programming Languages]: Language Classifications—Applicative (functional) languages; D.3.2 [Programming Languages]: Language Classifications—Concurrent, distributed and parallel languages; D.3.3 [Programming Languages]: Language Constructs and Features— Concurrent programming structures; D.3.4 [Programming Languages]: Processors—Runtime-environments General Terms
1.
Languages, Performance
• We give a complete description of GHC’s parallel runtime,
starting with an overview in Section 4, and amplified in the rest of the paper. A major constraint is that we do barely compromise the (excellent) execution speed of sequential code.
Introduction
At least in theory, Haskell has a head start in the race to find an effective way to program parallel hardware. Purity-by-default means that there should be a wealth of inherent parallelism in Haskell code, and the ubiquitous lazy evaluation model means that, in a sense, futures are built-in. How can we turn these benefits into real speedups on commodity hardware? This paper documents our experiences with building and optimising a parallel runtime for Haskell. Our runtime supports three models of parallelism: explicit thread-based concurrency (Peyton Jones et al. 1996), semi-explicit deterministic parallelism (Trinder et al. 1998), and data-parallelism (Peyton Jones et al. 2009). In this paper, however, we focus entirely on semiexplicit parallelism. Completely implicit parallelism is still a distant goal; one recent attempt at this in the context of Haskell can be found in Harris and Singh (2007). The semi-explicit GpH programming model, in contrast, has been shown to be remarkably effective (Loidl et al. 1999, 2003). The semantics of the program remains completely deterministic, and the programmer is not required to identify threads,
• We discuss several major sets of design choices, relating to
spark distribution, scheduling, and memory management (Sections 5 and 7); parallel garbage collection (Section 6); the implementation of mutual exclusion on thunks (Section 8); and load balancing and thread migration (Section 9). In each case we give quantitative measurements for a number of optimisations that we made over our baseline GHC 6.10 implementation. • While we focus mainly on the implementation, our work has
had some impact on the programming model: we identify the need for pseq as well as seq (Section 2.1), and we isolated a signficant difficulty in the “strategies” approach to writing parallel programs (Section 7). • On the way we developed a useful new profiling and tracing
tool (Section 10.1). Overall, our results are encouraging (Figure 1). The optimisations we describe improve the absolute runtime and scalability of all benchmarks, sometimes dramatically so. Before our work, some programs would speed up on a parallel machine, but others slowed down. Afterwards, using 7 cores of an 8-core machine yielded speedups in the range 3x to 6.6x, which is not bad for a modest investment of programmer effort. Some of the improvements were described in earlier work (Berthold et al. 2008), along with preliminary measurements, as part of a comparison between shared-heap and distributed-heap parallel execution models. In this paper we extend both the range
Permission to make digital or hard copies of all or part of this work for personal or classroom use is granted without fee provided that copies are not made or distributed for profit or commercial advantage and that copies bear this notice and the full citation on the first page. To copy otherwise, to republish, to post on servers or to redistribute to lists, requires prior specific permission and/or a fee. ICFP’09, August 31–September 2, 2009, Edinburgh, Scotland, UK. c 2009 ACM 978-1-60558-332-7/09/08. . . $5.00. Copyright
65
Speedup on 4 cores Program Before After gray 2.19 2.50 mandel 2.94 3.51 matmult 2.56 3.37 parfib 3.73 3.89 partree 0.74 1.99 prsa 3.28 3.56 ray 0.81 2.11 sumeuler 3.74 3.85
of measurements and the range of improvements, while focussing exclusively on shared-heap execution. All our results are, or will be, repeatable using a released version of the widely-used GHC compiler. Our results do not require special builds of the compiler or libraries: identical results will be obtainable using a standard binary distribution of GHC. At the time of writing, most of our developments have been made public in the GHC source code repository, and we expect to include the remaining changes in the forthcoming 6.12.1 release of GHC, scheduled for the autumn of 2009. The sources to our benchmark programs are available in the public nofib source repository.
2.
Speedup on 7 cores Program Before After gray 2.61 2.77 mandel 4.50 4.96 matmult 4.07 5.04 parfib 5.94 6.67 partree 0.68 3.18 prsa 5.22 5.23 ray 0.82 3.48 sumeuler 6.32 6.42
Figure 1. Speedup results
Background: programming model strategy is a function that may evaluate (parts of) its argument and create sparks, but has no interesting results:
The basic programming model is known as Glasgow Parallel Haskell, or GpH (Trinder et al. 1998), and consists of two combinators:
type Done = () done = () type Strategy a = a -> Done
par :: a -> b -> b pseq :: a -> b -> b
The semantics of par a b is simply the value of b, whereas the semantics of pseq is given by pseq a b
= ⊥, = b,
Strategies compose nicely; that is, we can build complex strategies out of simpler ones:
if a = ⊥ otherwise
rwhnf :: Strategy a rwhnf x = x ‘pseq‘ done
Informally, par stores its first argument as a spark in the spark pool, and then continues by evaluating its second argument. The intention is that idle processors can find (probably) useful work in the spark pool. Typically the first argument to par will be an expression that is shared by another part of the program, or will be an expression that refers to other such shared expressions. 2.1
parList :: Strategy a -> Strategy [a] parList strat [] = done parList strat (x:xs) = strat x ‘par‘ parList strat xs
Finally, we can combine a data structure with a strategy for evaluating it in parallel: using :: a -> Strategy a -> a using x s = s x ‘pseq‘ x
The need for pseq
The pseq combinator is used for sequencing; informally, it evaluates its first argument to weak-head normal form, and then evaluates its second argument, returning the value of its second argument. Consider this definition of parMap:
Here is how we might use the combinators to evaluate all the element of a (lazy) input list in parallel, and then add them up: psum :: [Int] -> Int psum xs = sum (xs ‘using‘ parList rwhnf)
parMap f [] = [] parMap f (x:xs) = y ‘par‘ (ys ‘pseq‘ y:ys) where y = f x ys = parMap f xs
3.
We now turn our attention from the programming model to the implementation. Our baseline is GHC 6.10.1, a mature Haskell compiler. Its performance on sequential code is very good, so the overheads of parallelism are not concealed by sloppy sequential execution. It has supported parallel execution for several years, but while parallel performance is sometimes good, it is sometimes surprisingly bad. The trouble is that it is hard to know why it is bad, because performance is determined by the interaction of four systems — the compiler itself, the GHC runtime system, the operating system, and the physical hardware — each of which is individually extremely complex. The rest of this paper reports on our experience of improving both the absolute performance and its consistency. To whet your appetite, Figure 1 summarises the cumulative improvement of the work we present, for 4 and 7 cores1 . Each table has 2 columns:
The intention here is to spark the evaluation of f x, and then evaluate parMap f xs, before returning the new list y:ys. The programmer is hoping to express an ordering of the evaluation: first spark y, then evaluate ys. The obvious question is this: why not use Haskell’s built-in seq operator instead of pseq? The only guarantee made by seq is that it is strict in both arguments; that is, seq a ⊥ = ⊥ and seq ⊥ a = ⊥. But this semantic property makes no operational guarantee about order of evaluation. An implementation could impose this operational guarantee on seq, but that turns out to limit the optimisations that can be applied when the programmer only cares about the semantic behaviour. Instead, we provide both pseq and seq (with and without an order-of-evaluation guarantee), to allow the programmer to say what she wants while leaving the compiler with as much scope for optimisation as possible. To our knowledge this is the first time that this small but important point has been mentioned in print. The pseq operator first appeared in GHC 6.8.1. 2.2
Making parallel programs run faster
1 Why
did we use only 7 of the 8 cores on our test system? In fact we did perform the measurements for all 8 cores, but found that the results were far less consistent than the 7 core results, and in some cases performance degraded significantly. On closer inspection the OS appeared to be descheduling one or more of our threads, leading to long pauses when the threads needed to synchronise. This effect is discussed in more detail in Section 10.1.
Strategies
In Algorithms + Strategies = Parallelism (Trinder et al. 1998), Trinder et al explain how to use strategies to modularise the construction of parallel programs. In brief, the idea is as follows. A
66
8
7
6
gray Speedup
5
mandel matmult
4
parfib partree
3
prsa ray
2
sumeuler 1
0 1
2
3
4
5
6
7
Number of CPUs
Figure 2. Speedup results • Before: speedup achieved by parallel execution using GHC
roughly one for each physical CPU. This overall scheme is wellestablished, but it is easier to sketch than to implement! Each Haskell thread runs on a finite-sized stack, which is allocated in the heap. The state of a thread, together with its stack, is kept in a heap-allocated thread state object (TSO). The size of a TSO is around 15 words plus the stack, and constitutes the whole state of a Haskell thread. A stack may grow by copying the TSO into a larger area, and may subsequently shrink again. Haskell threads are executed by a set of operating system threads, which we call worker threads. We maintain roughly one worker thread per physical CPU, but exactly which worker thread may vary from moment to moment, as we explain in Section 4.2. Since the worker thread may change, we maintain exactly one Haskell Execution Context (HEC) for each CPU2 . The HEC is a data structure that contains all the data that an OS worker thread requires in order to execute Haskell threads. In particular, a HEC contains
6.10.1, compared to the same program compiled sequentially with 6.10.1, with the parallel GC turned off. In GHC 6.10.1 the parallel GC tended to make things worse rather than better, so this column reflects the best settings for GHC 6.10.1. • After: our best speedup results, using the PARGC3 configura-
tion (Section 6). The improvements are substantial, especially for the most disappointing programs which actually ran slower when parallelism was enabled in 6.10.1. Section 10 gives more details about the experimental setup and the benchmark programs. Figure 2 shows the scaling results for each benchmark program after our cumulative improvements, relative to the performance of the sequential version. By “sequential” we mean that the singlethreaded version of the runtime system was used, in which par is a no-op, and there are no synchronisation overheads.
4.
• An Ownership Field, protected by a lock, that records which
Background: the GHC runtime
worker thread is currently animating the capability (zero if none is). We explain in Section 4.2 why we do not use the simpler device of a lock to protect the entire HEC data structure.
By way of background, we describe in this section how GHC runs Haskell programs in parallel. In the sections that follow we present various measurements to show the effectiveness of certain aspects of our implementation design. Each of our measurements compare two configurations of GHC. Many of our improvements are cumulative and it proved difficult to untangle the source-code dependencies from each other in order to be able to make each measurement against a fixed baseline, so in each case we will clearly state what the baseline is. 4.1
• A Message Queue, containing requests from other HECs. For
example, messages saying “Please wake up thread T” arrive here. • A Run Queue of threads ready to run.
2 In
the source code of the runtime system, a HEC is called a “Capability”. The HEC terminology comes from the lightweight concurrency primitives work (Li et al. 2007b). Others call the same abstraction a “virtual processor” (Fluet et al. 2008a).
The basic setup
The GHC runtime system supports millions of lightweight threads by multiplexing them onto a handful of operating system threads,
67
• An Allocation Area (Section 6). There is a single heap, shared
4 cores Program ∆ Time (%) gray +6.5 mandel -3.4 matmult -2.1 parfib +3.5 partree -1.2 prsa -4.7 ray -35.4 sumeuler +0.0 Geom. Mean -5.5
among all the HECs, but each HEC allocates into its own local allocation area. • GC Remembered Sets (Section 6.2). • A Spark Pool. Each invocation of par a b adds the thunk a to
the (current HEC’s) Spark Pool; this thunk is called a “spark”. • A Worker Pool of spare worker threads, and a Foreign Outcall
Pool of TSOs that are engaged in foreign calls (see Section 4.2). In addition there is a global Black Hole Pool, a set of threads that are blocked on black holes (see Section 8). An active HEC services work using the following priority scheme. Items lower down the list are only performed if there are no higher-priority items available.
Figure 3. The effect of adding work-stealing queues vs. GHC 6.10.1 This also explains why we don’t simply have a mutex protecting the HEC, which all the spare worker threads are blocked on. That approach would afford us less control in the sense that we often want to hand the HEC to a particular worker thread, and a simple mutex would not allow us to do that. Foreign calls are not the focus of this paper, but more details can be found in Marlow et al. (2004).
1. Service a message on the Message Queue. 2. Run a thread on the Run Queue; we use a simple round-robin scheduling order. 3. If any spark pool is non-empty, create a spark thread and start running it (see Section 5.3). 4. Poll the Black Hole Pool to see if any thread has become runnable; if so, run it.
5.
Faster sparks
We now discuss the first set of improvements, which relate to the handling of sparks.
All the state that a HEC needs for ordinary execution of Haskell threads is local to the HEC, so under normal execution a HEC proceeds without requiring any synchronisation, locks, or atomic instructions. Synchronisation is only needed when:
5.1
Sharing sparks
GHC 6.10.1 has a private Spark Pool for each HEC, but it uses a “push” model for sharing sparks, as follows. In between running Haskell threads, each HEC checks whether its spark pool has more than one spark. If so, it checks whether any other HECs are idle (a cheap operation that requires no atomic instructions); if it finds an idle HEC it gives one or more sparks to it, by temporarily acquiring ownership of the remote HEC and inserting the sparks in its pool. To make spark distribution cheaper and more asynchronous we re-implemented each HEC’s Spark Pool as a bounded workstealing queue (Arora et al. 1998; Chase and Lev 2005). A workstealing queue is a lock-free data structure with some attractive properties: the owner of the queue can push and pop from one end without synchronisation, meanwhile other threads can “steal” from the other end of the queue incurring only a single atomic instruction. When the queue is almost empty, popping also incurs an atomic instruction to avoid a race between the popping thread and a stealing thread. When a spark is pushed onto an already full queue, we have a choice between discarding the new spark or discarding one of the older sparks. Our current implementation discards the newer spark; we do not investigate this choice further in this paper. Figure 3 shows the effect of adding work-stealing queues to our baseline GHC 6.10.1. As we can see from the results, work-stealing for sparks is almost always beneficial, and increasingly so as we add more cores. It is of particular benefit to ray, where the task granularity is very small.
• Load balancing is needed (Section 9). • Garbage collection is required (Section 6). • Blocking on black holes (Section 8.1). • Performing an operation on an MVar, or an STM transaction. • Unblocking a thread on another HEC. • Throwing an exception to a thread on another HEC, or a
blocked thread. • Allocating large or immovable memory objects; since these
operations are relatively rare, we allocate such objects from single global pool. • Making a (safe) foreign call (Section 4.2).
4.2
7 cores Program ∆ Time (%) gray -2.1 mandel -4.9 matmult +1.6 parfib -1.2 partree -3.7 prsa -6.7 ray -66.1 sumeuler +1.4 Geom. Mean -14.4
Foreign calls
Suppose that a Haskell thread makes a foreign call to a C procedure that blocks, such as getChar. We do not want the entire HEC to seize up so, before making the call, the worker thread relinquishes ownership of the HEC, leaving the Haskell thread in a tidy state. The thread is then placed in the Foreign Outcall Pool so that the garbage collector can find it. We maintain a Worker Pool of worker threads for each HEC, each eager to become the worker that animates the HEC. When one worker relinquishes ownership, it triggers a condition variable that wakes up another worker from the Worker Pool. If the latter is empty, a new worker is spawned. What happens when the original worker thread W completes its call to getChar and wants to return? To return, it must re-acquire ownership of the HEC, so it must somehow displace any worker thread X that currently owns the HEC. To do so, it adds a message to the HEC’s Message Queue. When X sees this message, it signals W, and returns itself to the worker pool. Worker thread W wakes up, and takes ownership of the HEC. This approach is slightly better than directly giving W ownership of the HEC, because W might be slow to respond, and the HEC does not remain locked for the duration of the handover.
5.2
Choosing a spark to run
Because we use a work-stealing queue for our spark pools, stealing threads must always take the oldest spark in the pool. However, the HEC owning the spark pool has a choice between two policies: it can take the youngest spark from the pool (LIFO), or it can take the oldest spark (FIFO). Taking the oldest spark requires an atomic instruction, but taking the youngest spark does not. Figure 4 shows the effect of changing the default (FIFO) to LIFO. In most of our benchmarks this results in worse performance, because the older sparks tend to be “larger”.
68
4 cores Program ∆ Time (%) gray +10.1 mandel +4.5 matmult -0.4 parfib +0.2 partree -7.3 prsa -0.3 ray +10.0 sumeuler +0.3 Geom. Mean +2.0
7 cores Program ∆ Time (%) gray +5.3 mandel +6.2 matmult -1.2 parfib +1.0 partree -1.6 prsa -2.3 ray +18.7 sumeuler +17.9 Geom. Mean +5.2
which was to create a new thread for each activated spark. Our baseline for these measurements is GHC 6.10.1 plus work-stealingqueues (Section 5.1). Batching sparks is particularly beneficial to two of our benchmarks, matmult and ray, while it is a slight pessimisation for parfib on 7 cores. For ray the rationale is clear: there are lots of tiny sparks, so reducing the overhead for spark execution has a significant effect. For parfib we believe that the reduction in performance shown here is because the program is actually being more effective at exploiting parallelism, which leads to reduced performance due to lack of locality (Section 6); as we shall see later, this performance loss is recovered by proper use of parallel GC.
Figure 4. Using LIFO rather than FIFO for local sparks 4 cores Program ∆ Time (%) gray -0.7 mandel -1.5 matmult -8.2 parfib +0.6 partree -0.6 prsa -1.0 ray -31.2 sumeuler +0.2 Geom. Mean -5.9
6.
7 cores Program ∆ Time (%) gray +2.3 mandel +1.3 matmult -11.3 parfib +6.4 partree +0.3 prsa -1.0 ray -24.3 sumeuler -0.3 Geom. Mean -3.8
Figure 5. The effect of batching sparks 5.3
Garbage collection
The shared heap is divided into fixed-size (4kbyte) blocks, each with a block descriptor that specifies which generation it belongs to, along with other per-block information. A HEC’s Allocation Area simply consists of a list of such blocks. When any HEC’s allocation area is exhausted, a garbage collection must be performed. GHC 6.10.1 offers a parallel garbage collector (see Marlow et al. (2008)), but GC only takes place when all HECs stop together, and agree to garbage collect. We aim to keep this synchronisation overhead to a minimum by ensuring that we can stop a HEC quickly (Section 6.3). In future work we plan to relax the stop-the-world requirement and adopt some form of CPU-independent GC (Section 12.1). When a GC is required, we have the option of either • Performing a single-threaded GC. In this case, the HEC that
Batching sparks
initiated the GC waits for all the other HECs to cease execution, performs the GC, and then releases the other HECs.
To run a spark a, a HEC simply evaluates the thunk a to head normal form. To do so, it needs a Thread State Object. It makes no sense to create a fresh TSO for every spark, and discard it when the evaluation is complete for the garbage collector to recover. Instead, when a HEC has no work to do, it checks whether there are any sparks, either in the HEC’s local spark pool or in any other HEC’s spark pool (check the non-empty status of a spark pool does not require a lock). If there are sparks available, then the HEC creates a spark thread, which is a perfectly ordinary thread except that it runs the following steps in a loop:
• Performing a parallel GC. In this case, the initiating HEC sends
a signal to the other HECs, which causes them to become GC threads and await the start of the GC. Once they have all responded, the initiating HEC performs the GC initialisation and releases the other GC threads to perform GC. When the GC termination condition is reached, each GC thread waits at the GC exit barrier. The initiating HEC performs any post-GC tasks (such as starting finalizers), and then releases the GC threads from the barrier to continue running Haskell code.
1. If the local Run Queue or Message Queue is non-empty, exit.
3. If there were no sparks to steal, exit.
In a single-threaded program, it is often better to use singlethreaded GC for the quick young-generation collections, because the cost of starting up and shutting down the GC threads can outweigh the benefits of doing GC in parallel.
4. Evaluate the spark to weak-head-normal form.
6.1
2. Remove a spark from the local spark pool, or if that is empty, steal a spark from another HEC’s pool.
Avoiding synchronisation in parallel copying GC
Parallel copying GC normally requires each GC thread to use an atomic instruction to synchronise when copying an object, so that objects are not accidentally duplicated. The cost of these atomic instructions is high: roughly 30% of GC time (Marlow et al. 2008). However, as we suggested in that paper, it is possible to relax the synchronisation requirement where immutable objects are concerned. The only adverse effect from making multiple copies of an immutable object is a little wasted space, and we know from measurements that the rate of actual collisions is very low–typically less than 100 collisions per second of GC time–so the amount of wasted space is likely to be minuscule. Our parallel GC therefore adopts this policy, and avoids synchronising access to immutable objects. Figure 6 compares the two policies: the baseline is our current system in which we only lock mutable objects, compared to a modified version in which we lock every object during GC. As the results show, our optimisation of only locking mutable objects has a significant benefit on overall performance: without it, performance drops by over 7%. The effect
where “exit” means that the spark thread exits and performs no further work; its TSO will be recovered by a subsequent GC. A spark thread will therefore evaluate sparks to WHNF one after another until it can find no more sparks, or until there is other work to do, at which point it exits. This is a particularly simple strategy and works well: the cost of creating the spark thread is amortized over multiple sparks, and the spark thread gets out of the way quickly if any other work arrives. If a spark blocks on a black hole, since the spark thread is just an ordinary thread it will block in the usual way, and the scheduler will create another spark thread to continue running the available sparks. We don’t have to worry unduly about having too many spark threads, because a spark thread will always exit when there are other threads around. This reasoning does rely on sparks not being too large, however: many large sparks becoming blocked could lead to a large number of running spark threads. Figure 5 compares the effect of using the spark-batching approach described above to the approach taken in GHC 6.10.1,
69
7 cores Program ∆ Time (%) gray +18.7 mandel +9.4 matmult +4.5 parfib -0.5 partree +17.3 prsa +2.5 ray +4.8 sumeuler +5.8 Geom. Mean +7.6
4 cores Program ∆ Time (%) gray -3.9 mandel -1.4 matmult -0.5 parfib -0.5 partree +5.0 prsa +2.3 ray +3.4 sumeuler -0.3 Geom. Mean +0.5
Figure 7. Using the heap-limit for context switching
Figure 6. Effect of locking all closures in the parallel GC 6.3
is the most marked in benchmarks that do the most GC: gray, but is negligible in those that do very little GC: parfib. 6.2
7 cores Program ∆ Time (%) gray -9.7 mandel -2.1 matmult +0.0 parfib +0.4 partree +0.3 prsa -0.1 ray -2.8 sumeuler -1.7 Geom. Mean -2.0
Pre-emption and garbage collection
Since garbage collection is relatively frequent, and requires all HECs to halt, it is important that they all do so promptly. One way to do this would be to use time-based pre-emption; however that would essentially mandate the use of conservative GC, which we consider an unacceptable compromise. Hence in order to GC, we require that all HECs voluntarily yield at a safe point, leaving the system in a state where all the heap roots can be identified. The standard way to indicate to a running thread that it should yield immediately is to set its heap-limit register to zero, thus causing the thread to return to the HEC scheduler when it next tries to allocate. On a register-poor machine, we keep the heap-limit “register” in memory, in a block of “registers” pointed to by a single real machine register. In this case, it is easy for one HEC to set another HEC’s heap limit to zero, simply by overwriting the appropriate memory location. On a register-rich machine we can keep the heap limit in a real machine register, but it is then a good deal more difficult for one HEC to zap another HEC’s heap limit, since it is part of the register set of a running operating-system thread. We therefore explored two alternatives for register-rich architectures:
Remembered Sets
Remembered sets are used in generational GC to track pointers from older generations into younger ones, so that when collecting the younger generation we can quickly find all the pointers into that generation. Whenever a pointer into a younger generation is created, an entry must be added to the remembered set. There are many choices for the representation of the remembered set and the form of its associated write barrier (Blackburn and Hosking 2004). In GHC, because mutation is rare, we opted for a relatively expensive write barrier in exchange for an accurate remembered set. Our remembered set representation is a sequential store buffer, which contains the addresses of objects in the old generation that contain pointers into the new generation. Each mutable object is marked to indicate whether it is dirty or not; dirty objects are in the remembered set. The write barrier adds a new entry to the remembered set if and only if the object being mutated is in the old generation and is not already marked dirty. In our parallel runtime, each HEC has its own remembered set. The reasons for this are twofold:
• Keep the heap limit in memory. This slows the heap-exhaustion
check, but releases an extra register for argument passing.
• Even though appending to the remembered set is not a common
operation, it is common enough that the effect of including any synchronisation would be noticeable. Hence, we must be able to add new entries to the remembered set without atomic instructions.
• Keep the heap limit in a register, and implement pre-emption
by setting a separate memory-resident flag. The flag is checked whenever the thread’s current allocation block runs out, since it would be too expensive to insert another check at every heapcheck point. This approach is cheap and easy, but pre-emption is much less prompt: a thread can allocate up to 4k of data before noticing that a context-switch is required.
• Objects that have been mutated by the current CPU are likely
to be in its cache, so it is desirable to visit these objects by the garbage collector on the same CPU. This is particularly important in the case of threads: the stack of a thread, and hence its TSO, is itself a mutable object. When a thread executes, the stack will accumulate pointers to new objects, and so if the TSO resides in an old generation it must be added to the remembered set. Having HEC-local remembered sets helps to ensure that the garbage collector traverses a thread on the same CPU that was running the thread.
Figure 7 measures the benefit of using the heap-limit register to signal a context-switch, versus checking a flag after each 4k of allocation. We see a slight drop in performance at 4 cores, changing to an increase in performance at 7 cores. This technique clearly becomes more important as the number of cores and the amount of garbage collection increases: benchmarks like gray that do a lot of GC benefit the most.
One alternative choice for the remembered set is the card table. Card tables have the advantage that they can be updated by multiple threads without synchronisation, but they compromise on accuracy. More importantly for us, however, would be the loss of locality from using a single card table instead of per-HEC sequential store buffers. We do not have individual measurements for the benefit of using HEC-local remembered sets, but believe that it is essential for good performance of parallel programs. In GHC 6.10.1 remembered sets were partially localised: they were local during execution, but shared during GC. We subsequentially modified these partially HEC-local remembered sets to be fully localised.
6.4
Parallel GC and locality
When we initially developed the parallel GC, our goal was to improve GC performance, so we focused most of our effort on using parallelism to accelerate garbage collection for single-threaded programs (Marlow et al. 2008). In this case the key goal is achieving good load-balancing, that is, making sure that all of the GC threads have work to do. However, there is another factor working in the opposite direction: locality. For parallel programs, when GC begins each CPU already has a lot of data in its cache; in a sequential program only one CPU does. It would obviously make sense for each CPU to
70
• PARGC3: using parallel GC in both young and old generations,
∆ Time (%) PARGC2 PARGC3 +52.5 -11.9 +19.0 -9.9 -3.9 -8.1 -1.9 -6.4 -60.7 -65.8 +1.2 -4.4 -2.6 -18.0 -0.5 -1.4 -60.7 -65.8 +52.5 -1.4 -5.1 -19.3
PARGC1 and PARGC2 use work-stealing for load-balancing, PARGC3 uses no load-balancing. In terms of locality, PARGC2 will improve locality significantly by traversing most of the data reachable by parallel threads on the same CPU as the thread is executing. PARGC3 will improve locality further by not moving data from one CPU’s cache to another in an attempt to balance the work of GC. Figures 8 and 9 present the results, for 4 cores and 7 cores respectively. There are several aspects to these figures that are striking:
Figure 8. The effectiveness of parallel GC (4 cores)
• partree delivers an 80% improvement with PARGC3 on 7
Program gray mandel matmult parfib partree prsa ray sumeuler Min Max Geometric Mean
PARGC1 -3.9 +3.6 -14.6 -0.5 +5.0 -0.4 +2.0 +0.3 -14.6 +5.0 -1.2
without load-balancing.
cores, with most of the benefit coming with PARGC2. Clearly locality is vitally important in this benchmark. Program gray mandel matmult parfib partree prsa ray sumeuler Min Max Geometric Mean
PARGC1 +2.8 +8.9 -19.2 +0.9 -2.1 +3.2 -0.8 +5.1 -19.2 +8.9 -0.5
• gray and mandel degrade significantly with PARGC2, recover-
∆ Time (%) PARGC2 PARGC3 +106.2 -3.0 +57.5 -5.2 +14.1 -14.1 -1.5 -7.6 -70.3 -79.8 +16.7 +8.2 +6.2 -5.2 +0.0 -2.8 -70.3 -79.8 +106.2 +8.2 +3.7 -21.3
ing with PARGC3. Load-balancing appears to be having a significant negative effect on performance here. These are benchmarks that don’t achieve full speedup, so it is likely that when a GC happens, idle CPUs are stealing data from the busy CPUs, harming locality more than would be the case if all the CPUs were busy. • PARGC3 is almost always better than the other configurations.
There are of course other possible configurations. For instance, parallel GC in the old generation only without load balancing, or parallel GC in both generations but with load-balancing only in the old generation. We have performed informal measurements on these and other configuarations and found that on average they performed less well than PARGC3, although for individual benchmarks it is occasionally the case that a different configuration is a better choice. Future versions of GHC will use PARGC3 by default for parallel execution, although it will be possible to override the default and select any combination of parallel/sequenctial GC for each generation with and without load-balancing.
Figure 9. The effectiveness of parallel GC (7 cores)
garbage-collect its own data, so far as possible, rather than to allow GC to redistribute it. Each HEC starts by tracing its own root set, starting from the HEC’s private data (Section 4.1). However, our original parallel GC design used global work queues for load-balancing (Marlow et al. 2008). This is a poor choice for locality, because the link between the CPU that copies the data and the CPU that scans it for roots is lost. To tackle this, we modified our parallel GC design to use work-stealing queues. The benefits of this are threefold:
7.
The space behaviour of par
The spark pool should ideally contain only useful work, and we might hope that the garbage collector would assist the scheduler by removing useless sparks from the spark pool. One sure-fire way to do so is to remove any fizzled sparks. A spark has fizzled if the thunk to which it refers it has already been evaluated, so that running the spark would terminate immediately. Indeed, we expect most sparks to fizzle. The par operation creates opportunities for parallel evaluation but, if the machine is busy, few of these opportunities are taken up. For example, consider
1. Contention is reduced. 2. Locality is improved: a CPU will take work from its own queue in preference to stealing. Local work is likely to be in the CPU’s cache, because it consists of objects that this CPU recently copied. 3. We can easily disable load-balancing entirely, by opting not to steal any work from other CPUs. This trades parallelism in the GC for locality.
x ‘par‘ (y ‘pseq‘ (x+y)) This sparks the thunk x (adding it to the spark pool), evaluates y, and then adds x and y. The addition operation forces both its arguments, so if the sparked thunk x has not been taken up by some other processor, the addition will evaluate it. In that case, the spark has fizzled. Clearly a fizzled spark is useless, and the garbage collector can (and does in GHC 6.10) discard them, but which other sparks should the garbage collector retain? Two policies immediately spring to mind, that we shall call ROOT and WEAK:
The use of work-stealing queues for load-balancing in parallel GC is a well-known technique (Flood et al. 2001), however what has not been studied before is the trade-off between whether to do load-balancing at all or not for parallel programs. We will measure our benchmarks in three configurations: • The baseline is our best system, with parallel GC turned off. • PARGC1: using parallel GC in the old generation only, with
load-balancing.
• ROOT: Treat (non-fizzled) sparks as roots for the garbage col-
• PARGC2: using parallel GC in both young and old generations,
lector. That is, retain all such sparks and the graph they point to.
with load-balancing.
71
Strat. No Strat.
Total time(s) 10.7 6.4
MUT time(s) 5.1 5.2
GC time(s) 5.7 1.2
Total sparks 1000000 1000000
Fizzled Sparks 0 999895
that share no graph with the main program”. This is clearly an improvement on the ROOT policy because it lets us discard sparks that share nothing with the main program. However, it is quite difficult to establish whether there is any sharing between the spark and the main program, since this entails establishing a “reaches” property, where each closure in the graph is marked if it can reach certain other closures (namely the main program). This is exactly the opposite of the property that a garbage collector normally establishes, namely “is reachable from”, and is therefore at odds with the way the garbage collector normally works. It requires a completely new traversal, perhaps by reversing all the pointers in the graph. Even if we could implement this strategy, it does not completely solve the problem. A spark may share data with the main program, but that is not enough: it has to share unevaluated data, and that unevaluated data must be part of what the spark will evaluate. Moreover, perhaps we still want to discard sparks that are retaining a lot of unshared data, but still refer to a small amount of shared data, on the grounds that the cost of the space leak outweighs the benefits of any possible parallelism.
Figure 10. Comparison of ray using strategies vs. no strategies • WEAK: Only retain (non-fizzled) sparks that are reachable
from the roots of the program. The problem is, neither of these policies is satisfactory. WEAK seems attractive, because it lets us discard sparks that are no longer required by the program. However, the WEAK policy is completely incompatible with strategies. Consider the parList strategy: parList :: Strategy a -> Strategy [a] parList strat [] = done parList strat (x:xs) = strat x ‘par‘ parList strat xs
Each spark generated by parList is a thunk for the expression “strat x”; this thunk is not shared, since it is created uniquely for the purposes of creating the spark, and hence can never fizzle. Hence, the WEAK policy will discard all sparks created by parList, which is obviously undesirable. So, what about the ROOT policy? This is the policy that is used in existing implementations of GpH, including GUM (Trinder et al. 1996) and GHC. However, it leads to the converse problem: too many sparks are retained, leading to space leaks. Consider the expression
7.1
Improving space behaviour of sparks
One way to improve the space behaviour of sparks is to use the WEAK policy for garbage collection. This guarantees, by construction, that the spark pool does not leak any space whatsoever. However, this choice would affect the programming model. In particular we can no longer use the strategies abstraction as it stands, because every strategy combinator involves sparking unique, unshared thunks, which WEAK will discard immediately. It is for this reason that GHC still uses the ROOT policy: if we were to switch to WEAK, then existing code using Strategies would lose parallelism. We can continue to write parallel programs without space leaks under the ROOT policy, as long as we observe the rule that all sparks must be eventually evaluated. Then we can be sure that any unused sparks will fizzle, and in this case there is no difference between ROOT and WEAK. The following section describes how to write programs in this way. Nonetheless, we believe that in the long term the implementation should use the WEAK policy. The WEAK policy has one distinct advantage over ROOT, namely that it is possible to write programs that use speculative parallelism without incurring space leaks. A speculative spark is by its nature one that may or may not be eventually evaluated, and in order to ensure that such sparks are eventually garbage collected if they turn out not to be required, we need to use WEAK.
sum (parList rnf (map expensive [1..100000])) With the ROOT policy we will retain all of the sparks created by parList, and hence lose no parallelism. But if there are not enough processors to evaluate all of the sparks, they will never be garbage collected, even after the sum is complete! They remain in the spark pool, retaining the list elements that they point to. This can lead to serious space leaks3 . To quantify the effect, we compared two versions of the ray benchmark. The first version uses parBuffer from the standard strategies library, applied to the rwhnf strategy, while the second uses a modified version of parBuffer which avoids the space leak (we will explain how the modified version works in Section 7.2). We ran both versions of the program on a single CPU, to illustrate the degenerate case of having too few CPUs to use the available parallelism. Figure 10 gives the results; MUT is the amount of “mutator time” (execution time excluding garbage collection), GC is the time spent garbage collecting. We can see that with the strategies version, no sparks fizzle, and the GC time suffers considerably as a result4 . Implementations using the ROOT policy have been around for quite a long time, and yet the problem has only recently come to light. Why is this? We are not sure, but postulate that the applications that have been used to benchmark these systems do not suffer unduly from the space leaks, perhaps because the amount of extra space retained is small, and there is little or no speculation involved. If there are enough CPUs to use all the parallelism, then no space leaks are observed; the problem comes when we want to write a single program that works well when run both sequentially and in parallel. Are there any other policies that we should consider? Perhaps we might try to develop a policy along the lines of “discard sparks
7.2
Avoiding space leaks with ROOT
We can still use strategy-like combinators with ROOT, but they are no longer compositional. In the case of parList, if we simply want to evaluate each element to weak-head-normal-form, we use a specialised version of parList: parListWHNF :: Strategy [a] parListWHNF [] = done parListWHNF (x:xs) = x ‘par‘ parListWHNF xs
Now, as long as the list we pass to parListWHNF is also evaluated by the main program, the sparks will all be garbage collected as usual. The rule of thumb is to always put a variable on the left of par. Reducing the granularity with parListChunk is a common technique. The idea is for each spark to evaluate a fixed-size chunk of the list, rather than a single element. To do this without incurring a space leak means that the sparked list chunks must be concatenated into a new list, and returned to the caller:
3 In
fact, this space leak was reported to the GHC team as a bug, http: //hackage.haskell.org/trac/ghc/ticket/2185. 4 Why don’t all the sparks fizzle in the second version? In fact the runtime does manage to execute a few sparks while it is waiting for IO to happen.
72
4 cores Program ∆ Time (%) gray -2.1 mandel -1.0 matmult -7.8 parfib -1.0 partree +5.2 prsa -0.2 ray +1.5 sumeuler -1.2 Geom. Mean -0.9
parListChunkWHNF :: Int -> [a] -> [a] parListChunkWHNF n = concat . (‘using‘ parListWHNF) . map (‘using‘ seqList) . chunk n
where chunk :: Int -> [a] -> [[a]] splits a list into chunks of length n. A combinator that we find ourselves using often is parBuffer, which behaves like parList except that it does not traverse the whole list eagerly; it sparks a fixed number of elements initially, and then sparks subsequent elements as the list is consumed. This formulation works particularly well with programs that produce output as a lazy list, since it allows us to retain the constant-space property of the program while taking advantage of parallelism. The disadvantage is that we have to pick a buffer size, and the best choice of buffer size might well depend on how many CPUs we have available. Our modified version of parBuffer that avoids space leaks is parBufferWHNF:
7 cores Program ∆ Time (%) gray +7.5 mandel +1.8 matmult +4.4 parfib -8.6 partree +9.5 prsa -0.1 ray +0.2 sumeuler +1.7 Geom. Mean +1.9
Figure 11. The effect of eager black-holing
8.
Thunks and black holes
Suppose that two Haskell threads, A and B, begin evaluation of a thunk t simultaneously. Semantically, it is acceptable for them both to evaluate t, since they will get the same answer (Harris et al. 2005); but operationally it is better to avoid this duplicated work. The obvious way to do so is to lock every thunk when starting evaluation, but that is expensive; measurements in the earlier cited work demonstrate an increase in execution time of around 50%. So we considered several variants that trade a reduced overhead against the risk of duplicated work:
parBufferWHNF :: Int -> [a] -> [a] parBufferWHNF n xs = return xs (start n xs) where return (x:xs) (y:ys) = y ‘par‘ (x : return xs ys) return xs [] = xs
EagerBH: immediately on entry, thread A overwrites t with a black hole. If thread B sees a black hole, it blocks until A performs the update (Section 8.1). The “window of vulnerability”, in which a second thread might start a duplicate evaluation, is now just a few instructions wide. The cost compared to sequential execution is an extra memory store on every thunk entry.
start !n [] = [] start 0 ys = ys start !n (y:ys) = y ‘par‘ start (n-1) ys
7.3 Compositional strategies revisited We can recover a more compositional approach to strategies by changing their type. The existing Strategy type is defined thus:
RtsBH: enlists the runtime system, using the scheme described in Harris et al. (2005). The idea is to walk a thread’s stack whenever it returns to the scheduler, and “claim” each of the thunks under evaluation using an atomic instruction. If a thread is found to be evaluating a thunk already claimed by another thread, then we suspend the current execution and put the thread to sleep until the evaluation is complete. Since every thread will return to the scheduler at regular intervals (say, to do garbage collection), this ensures that we cannot continue to evaluate the same thunk in multiple threads indefinitely. The overhead is much less than locking every thunk because most thunks are entered, evaluated, and updated during a single scheduler timeslice.
type Strategy a = a -> Done
Suppose that instead we define Strategy as a projection, like this: type Strategy a = a -> a
then a Strategy can do some evaluation and sparking, and return a new a. In order to use this new kind of Strategy effectively, we need a new version of the par combinator: spark :: Strategy a -> a -> (a -> b) -> b spark strat a f = x ‘par‘ f x where x = strat a ‘pseq‘ a
The spark combinator takes a strategy strat, a value a, and a continuation f. It creates a spark to evaluate strat a, and then passes a new object to the continuation with the same value as a. When evaluated, this new object will cause the spark to fizzle and be discarded. Now we can recover compositional parList and seqList combinators:
PostCheck: As Harris et al. (2005) points out, if two threads both succeed in completing the evaluation of the same thunk, and its value itself contains more thunks, there is a danger that an unbounded amount of work can be duplicated. The PostCheck strategy adds a test just before the update to check whether the thunk has already been updated by another thread. This test does not use an atomic instruction, but reduces the chance of further duplicate work taking place.
parList :: Strategy a -> Strategy [a] parList strat xs = foldr f [] xs where f x xs = spark strat x $ \x -> xs ‘pseq‘ x:xs
In our earlier work (Harris et al. 2005) we measured of the overheads of locking every thunk, but said nothing about the overheads or work-duplication of the other strategies. GHC 6.10.1 implements RtsBH by default. Figure 11 shows the additional effect of EagerBH on our benchmark programs, for 4 and 7 cores. As you might expect, the effect is minor, because RtsBH catches almost all the cases that EagerBH does, except for very short-lived thunks which do not matter much anyhow. Figure 12 shows how many times RtsBH catches a duplicate computation in progress, both with and without adding EagerBH. As we can see, without EagerBH there are occasionally a substantial number of duplicate evaluations (eg. in ray), but EagerBH reduces
seqList :: Strategy a -> Strategy [a] seqList strat xs = foldr seq ys ys where ys = map strat xs
and indeed this works quite nicely. Note that parList requires linear stack space; it is also possible to write a version that only requires linear heap space, but that requires two traversals of the list. Here is parListChunk in the new style: parListChunk :: Int -> Strategy a -> Strategy [a] parListChunk n strat xs = ys ‘pseq‘ concat ys where ys = parList (seqList strat) $ chunk n xs
73
Program gray mandel matmult parfib partree prsa ray sumeuler
RtsBH 0 3 5 70 3 45 1261 0
4 cores Program ∆ Time (%) gray +2.9 mandel +4.2 matmult +42.1 parfib -0.3 partree +3.3 prsa -0.5 ray -5.4 sumeuler -0.2 Geom. Mean +5.0
RtsBH + EagerBH 0 0 5 1 3 0 0 0
Figure 12. The number of duplicate computations caught
Figure 13. Disabling thread migration
9.
that number to almost zero. In ray, although we managed to eliminate a large number of duplicate evaluations using EagerBH, the effect on overall execution time was negligible: this program creates 106 tiny sparks, so 1200 duplicate evaluations has little impact. In fact, with the fine granularity in this benchmark, it may be that the cost of suspending the duplicate evaluation and blocking the thread outweighs the cost of just duplicating the computation. To date we have not measured the effect of PostCheck. We expect it to have no effect on these benchmarks, especially in combination with EagerBH. However, we have experienced the effect of unbounded duplicate work in other programs; one good example where it can occur is in this version of parMap:
Load balancing and migration
In this section we discuss design choices concerning which HEC should run which Haskell threads. 9.1
Sharing runnable threads
In the current implementation, while we (now) use work-stealing for sparks, we use work-pushing for threads. That is, when a HEC detects that it has more than one thread in its Run Queue and there are other idle HECs, it distributes some of the local threads to the other HECs. The reason for this design is mostly historical; we could without much difficulty represent the Run Queue using a work-stealing queue and thereby use work-stealing for the loadbalancing of threads. We measured the effect that automatic thread migration has on the performance of our parallel benchmarks. Figure 13 shows the effect of disabling automatic thread migration, against a baseline of the PARGC3 configuration (Section 6.4). Since these are parallel, rather than concurrent, programs, the only way that multiple threads can exist on the Run Queue of a single CPU is when a thread becomes temporarily blocked (on a blackhole, Section 8.1), and then later becomes runnable again. As we can see from the results, often allowing migration makes no difference. Occasionally it is essential: for example matmult on 4 cores. And occasionally, as in ray, allowing migration leads to worse performance, possibly due to lost locality. Whether to allow migration or not is a runtime flag, so the programmer can experiment with both settings to find the best one.
parMap :: (a -> b) -> [a] -> [b] parMap f [] = [] parMap f (x:xs) = fx ‘par‘ (pmxs ‘par‘ (fx:pmxs)) where fx = f x pmxs = parMap f xs
This function sparks both the head and the tail of the list, instead of traversing the whole list sparking each element as in the usual parMap. The duplication problem occurs if two threads evaluate the pmxs thunk: then the tail of the list is duplicated, possibly resulting in a large number of useless sparks being created. 8.1
7 cores Program ∆ Time (%) gray +2.9 mandel +1.2 matmult -0.0 parfib +7.5 partree +7.0 prsa -1.5 ray -14.4 sumeuler +1.2 Geom. Mean +0.3
Blocking on a black hole
When a thread A tries to evaluate a black hole, it must block until the thread currently evaluating the black hole (thread B) completes the evaluation, and overwrites the thunk with (an indirection to) its value. In earlier implementations (before 6.6) we arranged that thread B would attach its TSO to the thunk, so that thread A could re-awaken B when it performed the update. But that requires expensive synchronisation on every update, in case the thunk by now has a sleeping thread attached to it. Since thunk updates are very common, but collisions (in which a sleeping thread attaches itself to a thunk) are very rare, GHC 6.10 instead optimises for the common case. Instead of attaching itself to the thunk, the blocked thread B simply polls the thunk, waiting for the update. Since a thunk can only be updated once, an update can therefore be performed without any synchronisation whatsoever, provided that writes are not re-ordered. Our earlier work (Harris et al. 2005) discusses these synchronisation issues in much more detail. GHC 6.10 maintains a single, global Black Hole Pool, which the HECs poll when they are otherwise idle, and at least once per GC. We have considered two alternative designs: (a) privatising the Black Hole Pool to each HEC; and (b) using the thread scheduler directly, by making the blocked thread sleep and retry the evaluation when it reawakens. We have not yet measured these alternatives but, since contention is rare (Figure 12), they will probably only differ in extreme cases.
9.2
Migrating on wakeup
A blocked thread can be woken up for various reasons: if it is blocked on a black hole, it is woken up when some HEC notices that the black hole has now been evaluated (Section 8.1); if it is blocked on an empty MVar, then it can be unblocked when another thread performs a putMVar operation on that MVar. When a thread is woken up, if it was previously running on another HEC, we have a choice: it can be placed on the Run Queue of the current HEC (hence migrating it), or we could arrange to awaken it on the HEC it was previously running on. In fact, we could wake it up on any HEC, but typically these two options are the most profitable. Moving the thread to the current HEC might be advantageous if the thread is involved in a series of communications with another thread on this HEC: context-switching between two threads on the same HEC is particularly cheap. However, locality might also be important: the thread might be referencing data that is in the cache of the other HEC. In GHC we take locality seriously, so our default is not to migrate awoken threads to the current CPU. For parallel programs, it is never worthwhile to change this setting, at least with the current implementation of black holes, since it is essentially random which
74
HEC awakens a blocked thread. If we were to change the implementation of black holes such that a thread can tell when an update should wake a blocked thread (perhaps by using a hash table to map the address of black holes to blocked threads), then there might be some benefit in migrating the blocked thread to the CPU on which the value it was waiting for resides.
10.
Benchmarks and experimental setup
Our test system consists of 2 quad-core Intel Xeon(R) E5320 processors at 1.6GHz. Each pair of cores shares 4MB of L2 cache, and there is 16GB of system memory. The system was running Fedora 9. Although the OS was running in 64-bit mode, we used 32-bit binaries for our measurements (programs compiled for 64-bit tend to place more stress on the memory system and garbage collector resulting in less parallelism). In all cases we ran the programs five times and took the average wall-clock execution time. Our benchmarks consist of a selection of small-to-mediumsized Parallel Haskell programs:
Figure 14. A slow synchronisation
These programs are all small, are mostly easy to parallelise, and are not highly optimised, so the results we report here should be interpreted as suggestive rather than conclusive. Nevertheless, our goal has not been to optimise the programs, but rather to optimise the implementation to make existing programs parallelise better. Furthermore, smaller benchmarks have their uses:
• parfib: the ubiquitous parallel fibonacci function, included here
as a sanity test to ensure that our implementation is able to parallelise micro-benchmarks. The parallelism is divide-andconquer-style, using explicit par and pseq. • sumeuler: the sum of the value of Euler’s function applied to
• Small benchmarks show up in high relief interesting differences
each integer up to a given bound. This is a map/reduce style problem: applications of the Euler function can be performed in parallel, and the results must be summed (Trinder et al. 2002). The parallelism is expressed using parListChunk from the strategies library.
in the behaviour of our runtime and execution model. These differences would be less marked had we used only large programs. • We know that most of these programs should parallelise well, so
any lack of parallelism is more likely to be as a result of choices made in the language implementation than in the program itself. Indeed, the lack of linear speedup in Figure 1 shows that we still have plenty of room for improvement.
• matmult: A naive matrix-multiply algorithm. The matrix is
represented as a [[Int]]. The parallelism is expressed using parListChunk. • ray: A ray-tracer benchmark5 . The parallelism is expressed
10.1
using parBuffer, and is quite fine-grained (each pixel to be rendered is a separate spark).
Profiling
To help understand the behaviour of our benchmark programs we developed a graphical viewer called ThreadScope for event information generated by the runtime system. The viewer is modeled after circuit waveform viewers with a profile drawn with time on the x-axis and HEC number on the y-axis. In each HEC’s timeline, the bar is coloured solid black when the HEC is running Haskell code with the thread ID inside the box, and gray when it is garbage collecting. Events, such as threads blocking or being woken up, are indicated by labels annotating the timeline when the view is zoomed enough. This visualisation of the execution is immensely useful for being able to quickly identify problem areas. For example, when we ran our benchmarks on all 8 cores of our 8-core machine, we experienced inexplicable drops in performance. Figure 14 shows one problem area as seen in the profile viewer. In the middle of the picture there is a long period where one HEC has initiated a GC, and is waiting for the other HECs to stop. The initiating/waiting HEC has a white bar, the HECs that have already stopped and are ready to GC are shown in gray. One HEC is running (black with thread ID 164) during this period, indicating that it is apparently running Haskell code and has not responded to the call for a GC. In fact, the OS thread running this HEC has been descheduled by the OS, so does not respond for a relatively long period. The same pattern repeats many times during the execution, having a significant impact on the overall runtime. This experience does illustrate that our runtime is particularly sensitive to problems such as this due to the relatively high frequency of full synchronisations needed for GC, and that tackling independent GC (Section 12.1) should be a high priority.
• gray: Another ray-tracing benchmark, this time taken from an
entry6 in the ICFP’00 programming contest. Only the rendering part of the program has been parallelised, using a parBuffer as above. According to time profiling, the program only spends about 50% of its time in the renderer, so we expect this to limit the parallelism we can achieve. The parallelism is expressed using a single parBuffer in the renderer. • prsa: A parallel RSA message encoder, encoding a 500KB
message. Parallelism is again expressed using parBuffer. • partree: A parallel map and fold over a tree. The program orig-
inates in the GUM benchmark suite, and in fact appears to be badly written: it is quadratic in the size of the tree. Nevertheless, it does appear to run in parallel, so we used the program unmodified for the purposes of benchmarking. • mandel: this is a mandelbrot-set program originating in the
nofib benchmark suite (Partain 1992). It generates a lazy list of pixel data (for a 1024x1024 scene), in a similar way to the ray tracer, and it was parallelised in the same way with the addition of parBuffer. The difference in this case is that the parallelism is more coarse-grained: each scan-line of the result is a separate spark. 5 This
program has a long history. According to comments in the source code, it was “taken from Paul Kelly’s book, adapted by Greg Michaelson for SML, converted to (parallel) Haskell by Kevin Hammond”. 6 from the Galois team
75
11.
Related work
run-time and we are considering re-running the benchmarks to measure any improvements. In particular we expect benchmarks that perform a lot of garbage collection to benefit from the parallel garbage collector. It is clear from our investigation of the programming model in Section 7 that we should change the GC policy for sparks from ROOT to WEAK, but we must also revisit the Strategies abstraction and develop a new library that works effectively under WEAK.
The design space for language features to support implict parallelism and the underlying run-time system is very large. Here we identify just a few systems that make different design decisions and trade-offs from the GHC run-time system. Like GHC the Manticore (Fluet et al. 2008b,a) system also supports implicit and explicit fine-grained parallelism which in turn has been influenced by previous work on data parallel languages like NESL (Blelloch et al. 1994) and Nepal/DPH (Chakravarty et al. 2001). Unlike NESL or Nepal/DPH, GHC also implements support for explicit concurrency as does Manticore. Many of the underlying implementation choices made for GHC and Manticore are interchangeable e.g. Manticore uses a partially shared heap whereas GHC uses a totally shared heap. Manticore however presents quite a different programming model based on parallel data structures (e.g. tuples and arrays) which provide a fork-join pattern of computation as well as a parallel case expression which can introduce non-determinism. Neither GHC nor Manticore support implicit parallelism without the need for user annotations which has been implemented in other functional languages like Id (Nikhl 1991), pH (Nikhl and Arvind 2001) and Sisal (Gaudiot et al. 1997). S TING (Jagannathan and Philbin 1992) is a system that supports multiple parallel language constructs for a dialect of SCHEME through three layers of process abstraction as well as special support for specifying scheudling policies. We also intend to modify GHC’s infrasturcture to allow different scheduling policies to be composed together in a flexible manner (Li et al. 2007a). GpH (Trinder et al. 1998) extended Haskell98 to introduce the parallel (par) and sequential (seq) coordination primitives and provides strategies for controlling the evaluation order. Unlike semi-implicit parallelism annotations in Haskell which identify opportunities for parallelsim, in Eden (Loogen et al. 2005) one explicitly creates processes which are always executed concurrently. GUM (Trinder et al. 1996) targets distributed systems and is based on message passing. In our work we profiled the execution time of Haskell threads and garbage collection. However, we will also need to perform space profiling and the work on the MLton project on semantic space profiling (Spoonhower et al. 2008) represents an interesting approach for a strict language. Erlang (Armstrong et al. 1996) provides isolated threads which communicate through a mailbox mechanism with pattern matching used to select messages of interest. These design decisions have a subtantial effect on the design of the run-time system. Eden (Loogen et al. 2005) provides special annontations to control parallel evaluation of processes. Cilk (Blumofe et al. 2001) is an imperative programming language based on C which also supports fine grain parallelism in a fork-join manner by spawning off parallel invocations of procedures. Like GHC Cilk also performs work-stealing for load balancing. The spawn feature of Cilk, expressions bound with pcase in Manticore and sparks in GHC can all be considered to be instances of futures.
12.
12.1
Independent GC
Stop-the-world GC will inevitably become more of a bottleneck as the number of cores increases. There are known techniques for doing CPU-independent GC (Doligez and Leroy 1993), and these techniques are used in systems such as Manticore (Fluet et al. 2008a). We fully intend to pursue CPU-independent GC in the future. However this is unlikely to be an easy transition. CPU-independent GC replaces direct sharing by physical separation and explicit communication. This leads to trade-offs; it isn’t a straightforward win. More specifically, CPU-independent GC requires a local-heap invariant, namely that there are no pointers between local heaps, or from the global heap into any local heap. Ensuring and maintaining this invariant introduces new costs and complexities into the runtime execution model. On the other hand, as the number of cores in modern CPUs increases, the illusion of shared memory begins to break down. We are already experiencing severe penalties for losing locality (Section 6.4), and it is likely that these will only get worse in the future. Hence, moving to more explicitly-separate heap regions is a more honest reflection of the underlying memory architecture, and is likely to allow the implementation to make intelligent decisions about data locality.
Acknowledgments We wish to thank Jost Berthold, who helped us identify some of the bottlenecks in the parallel runtime implementation, and built the first implementation of work-stealing queues for spark distribution during an internship at Microsoft Research in the summer of 2008. We also wish to thank Phil Trinder and Tim Harris for their helpful comments on an earlier draft of this paper.
References J. R. Armstrong, R. Virding, C. Wikstr¨om, and M. Williams. Concurrent programming in ERLANG (2nd ed.). Prentice Hall International (UK) Ltd., Hertfordshire, UK, 1996. Nimar S. Arora, Robert D. Blumofe, and C. Greg Plaxton. Thread scheduling for multiprogrammed multiprocessors. In In Proceedings of the Tenth Annual ACM Symposium on Parallel Algorithms and Architectures (SPAA), Puerto Vallarta, pages 119– 129, 1998. J. Berthold, S. Marlow, A. Al Zain, and K. Hammond. Comparing and optimising parallel Haskell implementations on multicore. In IFL’08: International Symposium on Implementation and Application of Functional Languages (Draft Proceedings), Hatfield, UK, 2008.
Conclusion and future work
Stephen M. Blackburn and Antony L. Hosking. Barriers: friend or foe? In ISMM ’04: Proceedings of the 4th international symposium on Memory management, pages 143–151, New York, NY, USA, 2004. ACM. ISBN 1-58113-945-4. doi: http://doi. acm.org/10.1145/1029873.1029891.
While we have achieved some significant improvements in parallel efficiency, our work clearly has some way to go; several benchmarks do not speed up as much as we might hope. Our focus in the future will therefore continue to be on using profiling tools to identify problem areas, and using those results to direct our attention to appropriate areas of the runtime system and execution model. The work on implicit parallelization described in Harris and Singh (2007) may benefit from the recent changes to the GHC
G. E. Blelloch, S. Chatterjee, J. C. Hardwick, J. Sipelstein, and M Zagha. Implementation of a portable nested data-parallel language. JDPC, 21(1):4–14, 1994.
76
R. D. Blumofe, C. F. Joerg, B. C. Kuszmaul, and C. E. Lieserson. Cilk: an efficient multithreaded runtime system. SIGNPLAN Not., 30(8):207–216, 2001.
grams in GPH. Concurrency — Practice and Experience, 11: 701–752, 1999. URL http://www.cee.hw.ac.uk/\~{}dsg/ gph/papers/ps/cpe.ps.gz.
M. T. Chakravarty, G. Keller, R. Leshinskiy, and W. Pfannenstiel. Nepal - Nested Data Parallelism in Haskell. LNCS, 2150, Aug 2001.
H.-W. Loidl, F. Rubio, N. Scaife, K. Hammond, S. Horiguchi, U. Klusik, R. Loogen, G. J. Michaelson, R. Pe na, S. Priebe, ´ J. Reb´on, and P. W. Trinder. Comparing parallel functional A languages: Programming and performance. Higher Order Symbol. Comput., 16(3):203–251, 2003. ISSN 1388-3690. doi: http://dx.doi.org/10.1023/A:1025641323400.
David Chase and Yossi Lev. Dynamic circular work-stealing deque. In SPAA ’05: Proceedings of the seventeenth annual ACM symposium on Parallelism in algorithms and architectures, pages 21–28, New York, NY, USA, 2005. ACM. ISBN 1-58113-9861. doi: http://doi.acm.org/10.1145/1073970.1073974.
Rita Loogen, Yolanda Ortega-Mall´en, and Ricardo Pena. Parallel Functional Programming in Eden. Journal of Functional Programming, 15(3):431–475, 2005.
Damien Doligez and Xavier Leroy. A concurrent, generational garbage collector for a multithreaded implementation of ml. In POPL ’93: Proceedings of the 20th ACM SIGPLAN-SIGACT symposium on Principles of programming languages, pages 113–123, New York, NY, USA, 1993. ACM. ISBN 0-89791560-7. doi: http://doi.acm.org/10.1145/158511.158611.
Simon Marlow, Simon Peyton Jones, and Wolfgang Thaller. Extending the haskell foreign function interface with concurrency. In Proceedings of the ACM SIGPLAN workshop on Haskell, pages 57–68, Snowbird, Utah, USA, September 2004. URL http://www.haskell.org/~simonmar/ papers/conc-ffi.pdf.
Christine Flood, Dave Detlefs, Nir Shavit, and Catherine Zhang. Parallel garbage collection for shared memory multiprocessors. In Usenix Java Virtual Machine Research and Technology Symposium (JVM ’01), Monterey, CA, 2001. URL citeseer.ist. psu.edu/flood01parallel.html.
Simon Marlow, Tim Harris, Roshan P. James, and Simon Peyton Jones. Parallel generational-copying garbage collection with a block-structured heap. In ISMM ’08: Proceedings of the 7th international symposium on Memory management. ACM, June 2008. URL http://www.haskell.org/~simonmar/ papers/parallel-gc.pdf.
Matthew Fluet, Mike Rainey, and John Reppy. A scheduling framework for general-purpose parallel languages. SIGPLAN Not., 43 (9):241–252, 2008a. ISSN 0362-1340. doi: http://doi.acm.org/ 10.1145/1411203.1411239.
R. S. Nikhl. ID language reference manual. Computer Science, MIT, Jul 1991.
Matthew Fluet, Mike Rainey, John Reppy, and Adam Shaw. Implicitly-threaded Parallelism in Manticore. International Conference on Functional Programming, pages 119–130, 2008b.
Laboratory for
R. S. Nikhl and Arvind. Implicit Parallel Programming in pH. Morgan Kaufmann Publishers, San Francisco, CA, 2001. WD Partain. The nofib benchmark suite of Haskell programs. In Functional Programming, Glasgow 1992, Workshops in Computing, pages 195–202. Springer Verlag, 1992.
J. L. Gaudiot, T. DeBoni, J. Feo, W. Bohm, W. Najjar, and P. Miller. The Sisal model of functional programming and its implementation. In As ’97, pages 112–123, Los Altimos, CA, March 1997. IEEE Computer Society Press.
S. Peyton Jones, A. Gordon, and S. Finne. Concurrent Haskell. In Proc. of POPL’96, pages 295–308. ACM Press, 1996. Simon Peyton Jones, Roman Leshchinskiy, Gabriele Keller, and Manuel M. T. Chakravarty. Harnessing the multicores: Nested data parallelism in Haskell. In FSTTCS 2009: IARCS Annual Conference on Foundations of Software Technology and Theoretical Computer Science, 2009.
Tim Harris and Satnam Singh. Feedback directed implicit parallelism. In ICFP ’07: Proceedings of the 12th ACM SIGPLAN international conference on Functional programming, pages 251– 264, New York, NY, USA, 2007. ACM. ISBN 978-1-59593815-2. doi: http://doi.acm.org/10.1145/1291151.1291192.
Daniel Spoonhower, Guy E. Blelloch, Robert Harper, and Phillip B. Gibbons. Space profiling for parallel functional programs. In ICFP ’08: Proceedings of the 12th ACM SIGPLAN international conference on Functional programming, pages 253–264, New York, NY, USA, 2008. ACM.
Tim Harris, Simon Marlow, and Simon Peyton Jones. Haskell on a shared-memory multiprocessor. In Haskell ’05: Proceedings of the 2005 ACM SIGPLAN workshop on Haskell, pages 49– 61. ACM Press, September 2005. ISBN 1-59593-071-X. doi: http://doi.acm.org/10.1145/1088348.1088354. URL http:// www.haskell.org/~simonmar/papers/multiproc.pdf.
P. W. Trinder, H.-W. Loidl, and R. F. Pointon. Parallel and distributed Haskells. J. Funct. Program., 12(5):469–510, 2002. ISSN 0956-7968. doi: http://dx.doi.org/10.1017/ S0956796802004343.
S. Jagannathan and J. Philbin. A customizable substrate for concurrent languages. In ACM Conference on Programming Languages Design and Implementation (PLDI’92), pages 55–81. ACM Press, June 1992.
PW Trinder, K Hammond, JS Mattson, AS Partridge, and SL Peyton Jones. GUM: a portable parallel implementation of haskell. In ACM Conference on Programming Languages Design and Implementation (PLDI’96). ACM Press, Philadelphia, May 1996.
P. Li, Simon Marlow, Simon Peyton Jones, and A. Tolmach. Lightweight concurrency primitives for GHC. In Haskell ’07: Proceedings of the 2007 ACM SIGPLAN workshop on Haskell, pages 107–118. ACM Press, September 2007a.
PW Trinder, K Hammond, H-W Loidl, and SL Peyton Jones. Algorithm + strategy = parallelism. Journal of Functional Programming, 8:23–60, January 1998.
Peng Li, Simon Marlow, Simon Peyton Jones, and Andrew Tolmach. Lightweight concurrency primitives for GHC. Haskell ’07: Proceedings of the ACM SIGPLAN workshop on Haskell workshop, June 2007b. URL http://www.haskell.org/ ~simonmar/papers/conc-substrate.pdf. H-W. Loidl, P.W. Trinder, K. Hammond, S.B. Junaidu, R.G. Morgan, and S.L. Peyton Jones. Engineering Parallel Symbolic Pro-
77
Effective Interactive Proofs for Higher-Order Imperative Programs ∗ Adam Chlipala
Gregory Malecha
Greg Morrisett
Avraham Shinnar
Ryan Wisnesky
Harvard University, Cambridge, MA, USA {adamc, gmalecha, greg, shinnar, ryan}@cs.harvard.edu
Abstract
1. Introduction
We present a new approach for constructing and verifying higherorder, imperative programs using the Coq proof assistant. We build on the past work on the Ynot system, which is based on Hoare Type Theory. That original system was a proof of concept, where every program verification was accomplished via laborious manual proofs, with much code devoted to uninteresting low-level details. In this paper, we present a re-implementation of Ynot which makes it possible to implement fully-verified, higher-order imperative programs with reasonable proof burden. At the same time, our new system is implemented entirely in Coq source files, showcasing the versatility of that proof assistant as a platform for research on language design and verification. Both versions of the system have been evaluated with case studies in the verification of imperative data structures, such as hash tables with higher-order iterators. The verification burden in our new system is reduced by at least an order of magnitude compared to the old system, by replacing manual proof with automation. The core of the automation is a simplification procedure for implications in higher-order separation logic, with hooks that allow programmers to add domain-specific simplification rules. We argue for the effectiveness of our infrastructure by verifying a number of data structures and a packrat parser, and we compare to similar efforts within other projects. Compared to competing approaches to data structure verification, our system includes much less code that must be trusted; namely, about a hundred lines of Coq code defining a program logic. All of our theorems and decision procedures have or build machine-checkable correctness proofs from first principles, removing opportunities for tool bugs to create faulty verifications.
A key goal of type systems is to prevent “bad states” from arising in the execution of programs. However, today’s widely-used type systems lack the expressiveness needed to catch language-level errors, such as a null-pointer dereference or an out-of-bounds array index, let alone library- and application-specific errors such as removing an element from an empty queue, failing to maintain the invariants of a balanced tree, or forgetting to release a critical resource such as a database connection. For safety- and securitycritical code, a type system should ideally let the programmer assign types to libraries such that client code cannot suffer from these problems, and, in the limit, the type system should make it possible for programmers to verify that their code is correct. There are many recent attempts to extend the scope of type systems to address a wider range of safety properties. Representative examples include ESC/Java (Flanagan et al. 2002), Epigram (McBride and McKinna 2004), Spec# (Barnett et al. 2004), ATS (Chen and Xi 2005), Concoqtion (Pasalic et al. 2007), Sage (Gronski et al. 2006), Agda (Norell 2007), and Ynot (Nanevski et al. 2008). Each of these systems integrates some form of specification logic into the type system in order to rule out a wider range of truly bad states. However, in the case of ESC/Java, Spec#, and Sage, the program logic is too weak to support full verification because these systems rely completely upon provers to discharge verification conditions automatically. While there have been great advances in the performance of automated provers, in practice, they can only handle relatively shallow fragments of first-order logic. Thus, programmers are frustrated when correct code is rejected by the type-checker. For example, none of these systems is able to prove that an array index is in bounds when the constraints step outside quantifier-free linear arithmetic. In contrast, Agda, ATS, Concoqtion, Epigram, and Ynot use powerful, higher-order logics that support a much wider range of policies including (partial) correctness. Furthermore, in the case of Ynot, programmers can define and use connectives in the style of separation logic (Reynolds 2002) to achieve simple, modular specifications of higher-order imperative programs. For example, a recent paper (Nanevski et al. 2008) coauthored by some of the present authors describes how Ynot was used to construct fullyverified implementations of data structures such as queues, hash tables, and splay trees, including support for higher-order iterators that take effectful functions as arguments. The price paid for these more powerful type systems is that, in general, programmers must provide explicit proofs to convince the type-checker that code is correct. Unfortunately, explicit proofs can be quite large when compared to the code. For example, in the Ynot code implementing dequeue for imperative queues, only 7 lines
Categories and Subject Descriptors F.3.1 [Logics and meanings of programs]: Mechanical verification; D.2.4 [Software Engineering]: Correctness proofs, formal methods, reliability General Terms
Languages, Verification
Keywords functional programming, interactive proof assistants, dependent types, separation logic ∗ This research was supported in part by a gift from Microsoft Research and a National
Science Foundation Graduate Research Fellowship.
Permission to make digital or hard copies of all or part of this work for personal or classroom use is granted without fee provided that copies are not made or distributed for profit or commercial advantage and that copies bear this notice and the full citation on the first page. To copy otherwise, to republish, to post on servers or to redistribute to lists, requires prior specific permission and/or a fee. ICFP’09, August 31–September 2, 2009, Edinburgh, Scotland, UK. c 2009 ACM 978-1-60558-332-7/09/08. . . $5.00 Copyright
79
of program code are required, whereas the proof of correctness is about 70 lines. This paper reports our experience re-designing and re-implementing Ynot to dramatically reduce the burden of writing and maintaining the necessary proofs for full verification. Like the original Ynot, our system is based on the ideas of Hoare Type Theory (Nanevski et al. 2006) and is realized as an axiomatic extension of the Coq proof assistant (Bertot and Cast´eran 2004). This allows us to inherit the full power of Coq’s dependent types for writing code, specifications, and proofs, and it allows us to use Coq’s facility for extraction to executable ML code. However, unlike in the previous version, we have taken advantage of Coq’s tactic language, Ltac, to implement a set of parameterized procedures for automatically discharging, or at least simplifying, the separation logic-style verification conditions. The careful design of these procedures makes it possible for programmers to teach the prover about new domains as they arise. We describe this new implementation of Ynot and report on our experience implementing and verifying various imperative data structures including stacks, queues, hash tables, binomial trees, and binary search trees. When compared with the previous version of Ynot, we observe roughly an order of magnitude reduction in proof size. In most cases, to realize automation, programmers need only prove key lemmas regarding the abstractions used in their interfaces and plug these lemmas into our extensible tactics. Additionally, we show that the tactics used to generate the proofs are robust to small changes in the code or specifications. In the next section, we introduce the new Ynot in tutorial style. Next, we describe the automation tactics that we built, report on further evaluation of our system via case studies, compare with related work, and conclude. 1.1 Coq as an Extensible Automated Theorem Prover Almost everyone familiar with Coq associates it with a particular style of proof development, which might be called the “video game” approach, after a comment by Xavier Leroy. A theorem is proved in many steps of manual interaction, where Coq tells the user which goals remain to be proved, the user enters a short command that simplifies the current goal somewhat, and the process repeats until no goals remain. One of our ancillary aims in this paper is to expose a broad audience to a more effective proof style. Coq provides very good support for fully automatic proving, via its domain-specific programming language Ltac (Delahaye 2000). This support can be mixed-and-matched with more manual proving, and it is usually the case that a well-written development starts out more manual and gradually transforms to a final form where no sequential proof steps are spelled out beyond which induction principle to use. Proof scripts of that kind often adapt without change to alterations in specifications and implementations. We believe that awareness of this style is one of the crucial missing pieces blocking widespread use of proof assistants. We hope that the reader will agree that some of the examples that follow provide evidence that, for programmers with a few years of training using proof assistants, imperative programming with correctness verification need not be much harder than programming in Haskell.
which uses dependency to capture the fact that div can only be called when a proof can be supplied that the second argument is non-zero. One can also write functions such as: Definition avg (x:list nat) : nat := let sum := fold plus 0 x in let len := length x in match eq_nat_dec len 0 with | inl(pf1: len = 0) => 0 | inr(pf2: len 0) => div sum len pf2 end. This function averages the values in a list of natural numbers. It has a normal type like you might find in ML, and its implementation begins in an ML-like way, using a higher-order fold function. The interesting part is the match expression. We match on the result of a call to eq nat dec, a dependently-typed natural number comparison function. This function returns a sum type with an equality proof in one branch and an inequality proof in the other. We bind a name for each proof explicitly in the pattern for each match case. The proof that len is not zero is passed to div to justify the safety of the operation. All Coq functions have to be pure – terminating without side effects. This is necessary to ensure that proofs really are proofs, with no spurious invalid “proofs by infinite loop.” Ynot extends Coq with support for side-effecting computations. Similarly to Haskell, we introduce a monadic type constructor ST T which describes computations that might diverge and that might have side effects, but that, if they do return, return values of type T. The ST type family provides a safe way to keep the effectful computations separate from the pure computations. Unlike Haskell’s IO monad, the ST type family is parameterized by a pre- and post-condition, which can be used to describe the effects of the computation on a mutable store. Alternatively, one can think of the axiomatic base of Ynot as a fairly standard Hoare logic. The main difference of our logic from usual presentations is that it is designed to integrate well with Coq’s functional programming language. Therefore, instead of defining a language of commands, we formalize a language of expressions in the style of the IO monad. A program derivation is of the form {P } e {Q}, where P is a pre-condition predicate over heaps, and Q is a postcondition predicate over an initial heap, the value that results from evaluating e, and a final heap. For instance, where we write sel and upd for the heap selection and update operations used in the ESC tools (Flanagan et al. 2002), we can derive the following facts, where p1 and p2 are pointer variables bound outside of the commands that we are verifying. {λ . ⊤} return(1) {λh, v, h′ . h′ = h ∧ v = 1} and {λh.sel(h, p1 ) = p2 } x ← !p1 ; x := 1 {λh, , h′ . sel(h, p1 ) = p2 ∧ h′ = upd(h, p2 , 1)} Unlike other systems, Ynot does not distinguish between programs and derivations. Rather, the two are combined into one dependent type family, whose indices give the specifications of programs. For instance, the type of the “return” example would be:
2. The Ynot Programming Environment
ST (fun _ => True) (fun h v h’ => h’ = h /\ v = 1)
To a first approximation, Coq can be thought of as a functional programing language like Haskell or ML, but with support for dependent types. For instance, one can have operations with types such as:
Heaps are represented as functions from pointers to dynamicallytyped packages, which are easy to implement in Coq with an inductive type definition. The pointer read rule enforces that the heap value being read has the type that the code expects. The original Ynot paper (Nanevski et al. 2008) contains further details of the base program logic.
div : nat -> forall n : nat, n 0 -> nat
80
{P1 } e1 {Q1 } ′
(∀x, {P2 (x)} e2 {Q2 })
′
{emp} return(v) {λv . [v = v ]}
(∀x, Q1 (x) ⇒ P2 (x))
{P1 } x ← e1 ; e2 {Q2 }
{emp} new(v) {λp. p 7→ v} {∃v, p 7→ v} free(p) {λ . emp} {∃v, p 7→ v ∗ P (v)} !p {λv. p 7→ v ∗ P (v)} {∃v, p 7→ v} p := v ′ {λ . p 7→ v ′ } P ⇒ P′
{P ′ } e {Q′ } Q′ ⇒ Q {P } e {Q}
{P } e {Q} {P ∗ R} e {Q ∗ R}
Figure 1. The main rules of the derived separation logic more standard separation logic. The old Ynot separation logic used binary post-conditions that may refer to both the initial and final heaps. (In both systems, specifications may refer to computation result values, so we avoid counting those in distinguishing between “unary” and “binary” post-conditions.) This is in stark contrast to traditional separation logics, where all assertions are separation formulas over a single heap, and all verification proof obligations are implications between such assertions. The utility of this formalism has been born out in the wealth of tools that have used separation logic for automated verification. In contrast, proofs of the binary post-conditions in the old Ynot tended to involve at least tens of steps of manual proof per line of program code. Today, even penciland-paper proofs about relationships between multiple heaps can draw on no logical formalism that comes close to separation logic in crispness or extent of empirical validation. While binary postconditions are strictly more expressive than unary post-conditions, the separation logic community has developed standard techniques for mitigating the problem. To make up for this lost expressiveness, we need, in effect, to move to a richer base logic. The key addition that lets us use a more standard formulation is the feature of computationallyirrelevant variables, which correspond to specification variables (also known as “ghost variables”) in standard separation logic. Such variables may be mentioned in assertions and proofs only, and an implementation must enforce that they are not used in actual computation. Coq∗ , a system based on the Implicit Calculus of Constructions (Barras and Bernardo 2008), supports this feature natively. From a theoretical standpoint, it would be cleanest to implement Ynot as a Coq∗ library. However, in implementing the original Ynot system, we hesitated to switch to this nonstandard branch of the Coq development tree. In designing the new system, we felt the same trepidation, since we might encounter difficulties using libraries written for the standard Coq system, and the users of our library would need to install an unusual version of Coq. We hope that, in the long term, the new Coq∗ features will become part of the standard Coq distribution. For now, we use an encoding of computationally-irrelevant variables that is effective in standard Coq, modulo some caveats that we discuss below. Our reimplementation employs the trick of representing specification variables in types that are marked as “proofs” instead of “programs,” such that we can take advantage of Coq’s standard restrictions on “information flow” from proofs to programs. Concretely, the Coq standard library has for some time contained a type family called inhabited, defined by:
2.1 A Derived Separation Logic Direct reasoning about heaps leads to very cumbersome proof obligations, with many sub-proofs that pairs of pointers are not equal. Separation logic (Reynolds 2002) is the standard tool for reducing that complexity. The previous Ynot system built a separation logic on top of the axiomatic foundation, and we do the same here. We introduce no new inductive type of separation logic formulas. Instead, we define functions that operate on arbitrary predicates over heaps, with the intention that we will only apply these functions on separation-style formulas. Nonetheless, it can be helpful to think of our assertion language as defined by: P
::=
[φ] | x 7→ y | P ∗ P | ∃x, P
For any pure Coq proposition φ, [φ] is the heap predicate that asserts that φ is true and the heap is empty. We write emp as an abbreviation for [True], which asserts only that the heap is empty. x 7→ y asserts that the heap contains only a mapping from x to y. P1 ∗ P2 asserts that the heap can be broken into two heaps h1 and h2 with disjoint domains, such that h1 satisfies P1 and h2 satisfies P2 . The final clause provides existential quantification. The embedding in Coq provides much more expressive formulas than in most systems based on separation logic. Not only can any pure proposition be injected with [·], but we can also use arbitrary Coq computation to build impure assertions. For instance, we can model deterministic disjunction with pattern-matching on values of algebraic datatypes, and we can include calls to custom recursive functions that return assertions. We need no special support in the assertion language to accommodate this, and Coq’s theorem-proving support for reasoning about pattern-matching recursive functions can be used without modification. If we had defined an inductive type of specifications, we would have needed to encode most of the relevant Coq features explicitly. For instance, to allow pattern matching that produces specifications, our inductive type would need a constructor standing for dependent pattern matching, which is quite a tall order on its own. Perhaps surprisingly, we have met with general success in implementing realistic examples using just these connectives. Standard uses of other connectives can often be replaced by uses of higher-order features, and the connectives that we do use are particularly amenable to automation. In Section 2.2, we try to give a flavor of how to encode disjunction, in the context of a particular example. Fully-automated systems like Smallfoot (Berdine et al. 2005) build in restrictions similar to ours, but it surprised us that we needed little more to do full correctness verification. 2.1.1 The Importance of Computational Irrelevance What we have described so far is the same as in the original Ynot work. The primary departure of our new system is that we use a
Inductive inhabited (A:Type) : Prop := inhabits : A -> inhabited A.
81
Module Type STACK. Parameter t : Set -> Set. Parameter rep (T : Set) : t T -> list T -> hprop.
This code demonstrates Coq’s standard syntax for inductive type definitions, which is quite similar to the syntax for algebraic datatype definitions in ML and Haskell. This type family has one parameter A of type Type, which can be thought of as the type of all types1 . The constructor inhabits lets us inject any value into inhabited. While the original value may have an arbitrary type, the inhabited package has a type in the universe Prop, the universe of logical propositions. Terms whose types are in this universe are considered to be proofs and are erased by program extraction. We will see in the following examples how this encoding necessitates some mildly cumbersome notation around uses of irrelevant variables. Further, to reason effectively about irrelevant variables, we need to assert without proof an axiom stating that the constructor inhabits is injective.
Parameter new T : Cmd emp (fun s : t T => rep s nil). Parameter free T (s : t T) : Cmd (rep s nil) (fun _ : unit => emp). Parameter push T (s : t T) (x : T) (ls : [list T]) : Cmd (ls ~~ rep s ls) (fun _ : unit => ls ~~ rep s (x :: ls)). Parameter pop T (s : t T) (ls : [list T]) : Cmd (ls ~~ rep s ls) (fun xo : option T => ls ~~ match xo with | None => [ls = nil] * rep s ls | Some x => Exists ls’ :@ list T, [ls = x :: ls’] * rep s ls’ end). End STACK.
Axiom pack_injective : forall (T : Set) (x y : T), inhabits x = inhabits y -> x = y. Our library additionally assumes the standard axiom of function extensionality (“functions are equal if they agree at all inputs”) and the very technical “unicity of equality proofs” axiom that is included in Coq’s standard library. This pair of axioms has been proved consistent for Coq’s logic, and we could avoid appealing to extensionality at the cost of more proving work in the library, by formalizing heaps as lists instead of functions. Such a change would be invisible to most users of the library, who only need to use standard theorems proved about the heap model. However, the pack injectivity axiom contradicts the axiom of proof irrelevance (which we do not use in any of our developments, but which is popular among Coq users), and it is an open question in the Coq community whether this axiom is consistent with Coq’s logic even by itself. Past work built a denotational model for Ynot minus this feature (Petersen et al. 2008), and the architects of that model are now considering how to add irrelevance, which would complete the foundational story for the framework that we use in this paper. We hope that the experiences we report here can help to justify the inclusion of irrelevance as a core Coq feature.
Figure 2. The signature of an imperative stack module read to be presented as an argument to the proof rule, but here we want to allow verification of programs where the exact value to read cannot be computed from the pieces of pure data that are in scope. We want to emphasize that the changes we have made in the Ynot separation logic have no effect on the theory behind the systems. In both the old and new systems, a separation logic is defined on top of the base Hoare logic with binary post-conditions, introducing no new axioms. Here, we use the same base logic as in the past work, so the past investigations into its metatheory (Petersen et al. 2008) continue to apply. The sole metatheoretical wrinkle is the one which we discussed above, involving computational irrelevance, which is orthogonal to program logic rules.
2.1.2 The Rules of the Separation Logic Figure 1 presents the main rules of our separation logic. The notable divergence from common formulations is in the use of existential quantifiers in the rules for freeing, reading, and writing. These differences make sense because Ynot is implemented within a constructive logic. Coq’s constructivity is inspired by the CurryHoward isomorphism, where programs and proofs can be encoded in the same syntactic class. A more standard, classical separation logic would probably require that, in the rule for free, the value v pointed to by p be provided as an argument to the proof rule. In constructive logic, such a value can only be produced when it can be computed by an algorithm, just as a functional program may only refer to a value that it has said how to compute. Additionally, we would not be able to use any facts implied by the current heap assertion to build one of these rule witnesses, and perhaps the witness can only be proved to exist using such facts. The explicit existential quantifier frees us to reason inside the assertion language in finding the witness. Because it uses quantification in this way, the “read” rule must also take a kind of explicit framing condition. This condition is parameterized by the value being read from the heap, making it a kind of description of the neighborhood around that value in the heap. More standard separation logics force the exact value being
In the rest of this section, we will introduce the Ynot programming environment more concretely, via several examples of verified data structure implementations. 2.2 Verifying an Implementation of Imperative Stacks Figure 2 shows the signature of a Ynot implementation of the stack ADT. The signature is expressed in Coq’s ML-like module system. Each implementation contains a type family t, where, for any type T, a value of t(T) represents a stack storing elements of T. The rep component of the interface relates an imperative stack s to a functional list ls in a particular state. Thus, rep s ls is a predicate on heaps (hprop) which can be read as “s represents the list ls” in the current state. Just as abstraction over the type family t allows an implementation to choose different data structures to encode the stack, abstraction over the assertion rep allows an implementation to choose different invariants connecting the concrete representation to an idealized model. In Section 2.1, we gave a grammar for our “specification language.” In contrast to most work on separation logic, our real implementation has no such specification language. Rather, we define the type hprop as heap -> Prop, so that specifications and invariants are arbitrary predicates over heaps. In Figure 2, we see notations involving emp, asserting that the heap is empty; [...], for injecting pure propositions; *, for the standard separating conjunction; and Exists, for standard typed existential quantification.
1 To
avoid the standard soundness problems with including a type of all types, actual Coq type-checking infers numerical indices for all occurrences of Type.
82
Not shown in this figure is the binary “points-to” operator -->. The relative parsing precedences of the operators place --> highest, followed by * and Exists. We only need to use funny symbols for syntax like Exists x :@ T, P (meaning “there exists x of type T such that P”) to avoid confusing the LL(1) parser that is at the heart of Coq’s syntax extension facilities. Our library defines hprop-valued functions implementing these usual separation logic connectives, but users can define their own “connectives” just as easily. For example, here is how we define Exists:
The type of pop showcases how we avoid the disjunctive connectives of separation logic. The function returns an optional T value, which will be None when the stack is empty and will be Some x when x is at the top of the stack. We use a Coq match expression to give a different post-condition for each case. We can implement a module satisfying this signature. With the type T as a local variable, we can define the type of nodes of the linked lists that we will use. We use the abstract type ptr of untyped pointers from the Ynot library.
Definition hprop_ex (T : Type) (p : T -> hprop) := fun h : heap => exists v : T, p v h.
Record node : Set := Node { data : T; next : option ptr }.
Here is how we add a syntax extension (or “macro”) that lets us write existential quantification in the way seen in Figure 2: Notation "’Exists’ v :@ T , p" := (hprop_ex T (fun v : T => p)).
To define the representation invariant, we want a recursive function specifying what it means for a possibly-null pointer to represent a functional list. Our code contains a struct annotation that gives a termination argument for the function.
By reading the types of the methods exposed in the STACK signature, we can determine the contract that each method adheres to. The Cmd type family is our parameterized monad of computations with separation logic specifications; the two arguments to Cmd give preconditions and postconditions. Cmd is defined in terms of the more primitive ST parameterized monad, in the same way as in our past work (Nanevski et al. 2008)2 . Our specifications follow the algebraic approach to proofs about data abstraction (as in Liskov and Zilles (1975)), where an abstract notion of state is related to concrete states. Each operation needs a proof that it preserves the relation properly. In Ynot developments, abstract states are manipulated by standard, purely-functional Coq programs, and method specifications include explicit calls to these state transformation functions. Each post-condition requires that the new concrete, imperative state be related to the abstract state obtained by transforming the initial abstract state. The type of the new operation tells us that it expects an empty heap on input, and on output the heap contains just whatever mappings are needed to satisfy the representation invariant between the function return value and the empty list. The free operation takes a stack s as an argument, and it expects the heap to satisfy rep on s and the empty list. The post state shows that all heap values associated with s are freed. The specification for push says that it expects any valid stack as input and modifies the heap so that the same stack that stood for some list l beforehand now stands for the list x :: l, where x is the appropriate function argument. We see an argument ls with type [list T]. The brackets are a notation defined by the Ynot library, standing for computational irrelevance. The syntax [T] expands to inhabited T. To review our discussion from Section 2.1.1, this means that the type-checker should enforce that the value of ls is not needed to execute the function. Rather, such values may only be used in stating specifications and discharging proof obligations. We use Coq’s notation scope mechanism to overload brackets for writing irrelevant types and lifted pure propositions. For an assertion P that mentions the irrelevant variable v, the notation v ~~ P must be used to unpack v explicitly. The type of the unpack operation is such that it may only be applied to assertions and may not be used to allow an irrelevant variable’s value to leak into the computational part of a program. Unpacking has no “logical” meaning; it is only used to satisfy the type-checker in the absence of native support for irrelevance. The notation is defined by this equation, where we write “[v’/v]” informally to denote the substitution of variable v’ for variable v in a Coq term.
Fixpoint listRep (ls : list T) (hd : option ptr) {struct ls} : hprop := match ls with | nil => [hd = None] | h :: t => match hd with | None => [False] | Some hd => Exists p :@ option ptr, hd --> Node h p * listRep t p end end. We can represent stacks as untyped pointers to the heads of linked lists built from Nodes. Definition stack := ptr. We achieve type safety through the representation invariant. Definition rep (s : stack) (ls : list T) : hprop := Exists po :@ option ptr, s --> po * listRep ls po. Before we start implementing the ADT methods, we should set up some proof automation machinery. Systems like Smallfoot (Berdine et al. 2005) have hardcoded support for particular heap predicates like acyclic linked list-ness, cyclic linked list-ness, and so on. These systems perform simplifications on formulas that mention the predicates that they understand. In Ynot, on the other hand, the programmer can define his own new predicates, as we have just done. Not only that, but he can also prove lemmas that correspond to the simplification rules built into automated tools, and he can plug his lemmas into a general separation logic solver. All of this is done with no risk that a mistake by the programmer will lead to a faulty verification; every lemma must be proved from first principles. In a real proof, of course, the human proof architect only learns which automation will be effective in the course of verifying his program. Ynot supports this kind of incremental automation very well, as we hope to demonstrate in the rest of the section, using sample interactive Coq sessions. Due to space constraints, we must skip some steps and go straight to the right answers, but we have tried to include enough iteration to give a flavor for Ynot development. As we progress through the methods, we will be improving a custom tactic, or proof procedure, that we will design specifically for this data structure, and we will call that tactic tac. We begin with a version of tac that delegates all work to the separation logic simplifier sep that is included with Ynot.
v ~~ P = (exists v’, v = inhabits v’ /\ P[v’/v]) 2 The
derived monad is called “STsep” in that past work.
83
tested is rebound with a non-option type. We use ;; instead of ; after imperative commands that do not bind variables, because attempts to do otherwise confuse Coq’s finicky LL(1) parser.
Ltac tac := sep fail auto. We will explain each of the two parameters to sep as we find a use for it. We implement each stack method by stating its type as a proof search goal, using tactics to realize the goal step by step. The first method to implement is new, and we do so using the syntax New for the new(·) operation from Figure 1.
Definition pop (s : stack) (ls : [list T]) : Cmd (ls ~~ rep s ls) (fun xo : option t => ls ~~ match xo with | None => [ls = nil] * rep s ls | Some x => Exists ls’ :@ list T, [ls = x :: ls’] * rep s ls’ end). refine (fun s ls => hd Some hd0 * listRep x (Some hd0) ==> hd0 --> ?1960 * hd0 --> ?1960 We can tell that something has probably gone wrong, since the conclusion of the implication contains an unsatisfiable separation formula that mentions the same pointer twice. Our automated separation simplification is quite aggressive and often simplifies satisfiable formulas to unsatisfiable forms, but the results of this process tend to provide hints about which facts would have been useful. In this case, we see a use of listRep where the pointer is known to be non-null. We can prove a lemma that would help simplify such formulas.
v --> None ==> rep v nil
Theorem listRep_Some : forall (ls : list T) (hd : ptr), listRep ls (Some hd) ==> Exists h :@ T, Exists t :@ list T, Exists p :@ option ptr, [ls = h :: t] * hd --> Node h p * listRep t p. destruct ls; sep fail ltac:(try discriminate). Qed.
The syntax ==> is for implication between heap assertions, and it has lower parsing precedence than any of the other operators that we use. We see that it is important to unfold the definition of the representation predicate, so we modify our tactic definition, and now the proof completes automatically. Ltac tac := unfold rep; sep fail auto.
We prove that a functional list related to a non-null pointer decomposes in the expected way. All it takes is for us to request a case analysis on the variable ls, followed by a call to the separation solver. Here we put to use the second parameter to sep, which gives a tactic to try applying throughout proof search. The discriminate tactic solves goals whose premises include inconsistent equalities over values of datatypes, like nil = x :: ls; and adding try in front prevents discriminate from signaling an error if no such equality exists. We can modify our tac tactic to take listRep Some into account. First, we define another procedure for simplifying an implication.
The definitions of free and push are not much more complicated. We use some new notations, including a Haskell-inspired monadic bind syntax, and all are defined in our library with “Coq macros,” as in the example of double braces above. Definition free (s : stack) : Cmd (rep s nil) (fun _ : unit => emp). refine (fun s => {{Free s}}); tac. Qed. Definition push (s : stack) (x : T) (ls : [list T]) : Cmd (ls ~~ rep s ls) (fun _ : unit => ls ~~ rep s (x :: ls)). refine (fun s x ls => hd sepBind (sepStrengthen (sepRead v)) (fun nd -> sepSeq (sepStrengthen (sepFrame (sepFree v))) (sepSeq (sepStrengthen (sepFrame (sepWrite s (next nd)))) (sepWeaken (sepStrengthen (sepFrame (sepReturn (Some (data nd)))))))) | None -> sepWeaken (sepStrengthen (sepFrame (sepReturn None))))
Notice that all specification variables and proofs are eliminated automatically by the Coq extractor. With the erasure of weakening and related operations, we arrive at exactly the kind of monadic code that is standard fare for Haskell, such that the compilation techniques developed for Haskell can be put to immediate use in creating an efficient compilation pipeline for Ynot. It is also worth pointing out that the sort of tactic construction effort demonstrated here is generally per data structure, not per program. We can verify a wide variety of other list-manipulating programs using the same tac tactic that we developed here. Usually, the tactic work for a new data structure centers on identifying the kind of unfolding lemmas that we proved above. 2.3 Verifying Imperative Queues It is not much harder to implement and verify a queue structure. We define an alternate list representation, parameterized by head and tail pointers. Fixpoint listRep (ls : list T) (hd tl : ptr) {struct ls} : hprop := match ls with | nil => [hd = tl] | h :: t => Exists p :@ ptr, hd --> Node h (Some p) * listRep t p tl end.
Figure 3. Sample OCaml code extracted from the stack example We also suggest to sep that try discriminate may be useful throughout proof search. Ltac tac := unfold rep; sep simp_prem ltac:(try discriminate). When we rerun the definition of pop, we have made progress. Only one goal remains to prove:
Record queue : Set := Queue { front : ptr; back : ptr }.
emp ==> [x = nil] We see that this goal probably has to do with a case where we know that the list being modeled is nil. We were successful at using simpl prem to deal with the case where we know the list is non-nil, and we can continue with that strategy by proving another lemma.
Definition rep’ (ls : list T) (fr ba : option ptr) := match fr, ba with | None, None => [ls = nil] | Some fr, Some ba => Exists ls’ :@ list T, Exists x :@ T, [ls = ls’ ++ x :: nil] * listRep ls’ fr ba * ba --> Node x None | _, _ => [False] end.
Theorem listRep_None : forall ls : list T, listRep ls None ==> [ls = nil]. destruct ls; sep fail idtac. Qed. Now our verification of pop completes, after we modify the definition of simp prem:
Definition rep (q : queue) (ls : list T) := Exists fr :@ option ptr, Exists ba :@ option ptr, front q --> fr * back q --> ba * rep’ ls fr ba.
Ltac simp_prem := simpl_IfNull; simpl_prem ltac:(apply listRep_None || apply listRep_Some).
For this representation, we prove similar unfolding lemmas to those we proved for stacks, with comparable effort. We also need a new lemma for unfolding a queue from the back.
We complete the implementation of the stack ADT with a trivial definition of the type family t, relying on the representation invariant to ensure proper use.
Lemma rep’_back : forall (ls : list T) (fr ba : ptr), rep’ ls (Some fr) ba ==> Exists nd :@ node, fr --> nd * Exists ls’ :@ list T, [ls = data nd :: ls’] * match next nd with | None => [ls’ = nil] | Some fr’ => rep’ ls’ (Some fr’) ba end.
Definition t (_ : Set) := stack. For our modest efforts, we can now extract an executable OCaml version of our module. Figure 3 shows part of the result of running Coq’s automatic extraction command on our Stack module. In the implementation of pop, we see invocations of functions whose names begin with sep. These come from the Ynot library, and we must provide their OCaml implementations. Any Ynot program that returns a type T may be represented in unit -> T in OCaml, regardless of the specification appearing in the original Coq type. This makes it easy to implement the basic functions, in the spirit of how the Haskell IO monad is implemented. We see calls to explicit weakening, strengthening, and framing rules in the extracted code. In OCaml, these can be implemented as no-ops and erased by an optimizer.
The proof of the lemma relies on some lemmas about pure functional lists. With those available, we prove rep’ back in under 20 lines. When we plug this and the two other unfolding lemmas into the sep procedure, we arrive at quite a robust proof procedure for separation assertions about lists that may be modified at either end. Again, in our final queue implementation, every proof obligation is proved by a tac tactic built from sep. We write under 10 lines of new tactic hints to be applied during proof search, and we
85
Module Type MEMO. Parameter T : Set. Parameter t : forall (T’ : T -> Set), hprop -> (forall x, T’ x -> Prop) -> Set. Parameter rep : forall (T’ : T -> Set) (inv : hprop) (fpost : forall x, T’ x -> Prop), t inv fpost -> hprop. Parameter create : forall (T’ : T -> Set) (inv : hprop) (fpost : forall x, T’ x -> Prop), (forall x, Cmd inv (fun y : T’ x => [fpost _ y] * inv)) -> Cmd emp (fun m : t inv fpost => rep m). Parameter funcOf : forall (T’ : T -> Set) (inv : hprop) (fpost : forall x, T’ x -> Prop) (m : t inv fpost), forall (x : T), Cmd (rep m * inv) (fun y : T’ x => rep m * [fpost _ y] * inv). End MEMO.
must prove one key lemma by induction. We discover the importance of this lemma while trying to verify an implementation of enqueueing. Definition enqueue : forall (q : queue) (x : T) (ls : [list T]), Cmd (ls ~~ rep q ls) (fun _ : unit => ls ~~ rep q (ls ++ x :: nil)). refine (fun q x ls => ba ls ~~ [res = ls] * listRep ls hd). refine (Fix2 (fun hd ls => ls ~~ listRep ls hd) (fun hd ls res => ls ~~ [res = ls] * listRep ls hd) (fun self hd ls => IfNull hd Then {{Return nil}} Else fn listRep (ls ++ x :: nil) fr nd. Hint Resolve himp_comm_prem. induction ls; tac. Qed. To get the original verification to go through, we only need to add this lemma to the hint database, using a built-in Coq command. Hint Immediate push_listRep. 2.4 Loops Like with many semi-automated verification systems, we require annotations that are equivalent to loop invariants. Since Coq’s programming language is functional, it is more natural to write loops as recursive functions, and the loop invariants become the pre- and post-conditions of these functions. We support general recursion with a primitive fixpoint operator in the base program logic, and it is easy to build a separation logic version on top of that. We can also build multiple-argument recursive and mutually-recursive function forms on top of the singleargument form, without needing to introduce new primitive combinators. An example is a getElements function, defined in terms of the list invariant that we wrote for the stack example. This operation returns the functional equivalent of an imperative list. The task is not so trivial as it may look at first, because the computational irrelevance of the function’s second argument prohibits its use to influence the return value. This means that we are not allowed to name the irrelevant argument as one that decreases on each recursive call, which prevents us from using Coq’s native recursive function definitions, where every function must be proved to terminate using simple syntactic criteria. Nonetheless, the definition is easy using the general recursion combinators supported by Ynot.
The code demonstrates a use of one of the derived fixpoint combinators, Fix2. Of the three arguments that we pass, the first two give the pre-condition and post-condition in terms of the two “real” arguments (and, for the post-condition, the return value). The third argument is the function body. It takes a recursive self-reference as its first argument, followed by the two “real” arguments. Native Coq recursive function definitions must often include annotations explaining why they terminate, but Ynot deals only with partial correctness, so no such annotations are required for our fixpoint combinators. The notation x ~~~ e is for building a new computationallyirrelevant value out of an old one. The notation e P is explicit invocation of the frame rule. With the current system, one usually wants to invoke that rule at each function call. The framing assertion can be written as an underscore to ask that it be inferred. 2.5 A Dependently-Typed Memoizing Function As far as we have been able to determine, all previous tools for data structure verification lack either aggressive automation or support for higher-order features. The original Ynot supported easy integration of higher-order functions and dependent types, but the very manual proof style became even more onerous for such uses. Our reimplemented Ynot maintains the original’s higher-order features,
Definition getElements (hd : option ptr) (ls : [list A]) :
86
and our proof automation integrates very naturally with them. This is a defining advantage of our new framework over all alternatives. For instance, it is easy to define a module supporting memoization of imperative functions. Figure 4 gives the signature of our implementation, which is actually an ML-style functor that produces implementations of this signature when passed appropriate input modules. The type T is the domain of memoizable functions, and types like t inv fpost stand for memo tables. The argument inv is an assertion giving an invariant that the memoized function maintains, and the pure assertion fpost gives a relation between inputs and outputs of the function. The rep predicate captures the heap invariants associated with a memo table. The create function produces a memo table when passed an imperative function with the proper specification. Finally, the funcOf function maps a memo table to a function that consults the table to avoid recomputation. We can implement a MEMO functor in 50 lines when we use a memo table that only caches the most recent input-output pair. Like in the previous examples, we build a specialized automation procedure with a one-line instantiation of library tactics. We give a 7-line definition of rep, give one one-liner proof of a lemma to use in proof search, and include two lines of annotations within the definition of funcOf. All of the rest of the development is no longer or more complicated than in ML. Compared to ML, we have the great benefit of using types to control the behavior of functions to be memoized. A function could easily thwart an ML memoizer by producing unexpected computational effects.
The effective range of specifications is too large to be solvable by any particular “magic bullet” tactic. Nonetheless, we have found that, in practice, a specific parameterized proof strategy can discharge most obligations. In contrast to the situation with classical verification tools that are backed by automated first-order theoremprovers, when any proof strategy fails in Coq, the user can always program his own new strategy or even move to mostly-manual proof. However, our experience suggests to us that most goals about data structures can be solved by the procedure that we present in this section. That procedure is implemented as the sep tactic that we used in our examples. We do not have space to include the literal Coq code implementing it; we will outline the basic procedure instead. The implementation is in Coq’s Ltac language (Delahaye 2000), a domain-specific, dynamically-typed language for writing proofgenerating proof search procedures. All of the proof scripts we have seen so far are really Ltac programs. The full language includes recursive function definitions, which, along with pattern-matching on proof goals, makes it possible to code a wide variety of proof manipulation procedures. As our examples have illustrated, sep takes two arguments, which we will call unfolder and solver. The task of unfolder is to simplify goals before specification inference, usually by unfolding definitions of recursive predicates, based on known facts about their arguments. The task of solver is to solve all of the goals that remain after generic separation logic reasoning is applied. Coq comes with the standard tactic tauto, for proving propositional tautologies. There is a more general version of tauto called intuition, which will apply a user-supplied tactic to finish off sub-proofs, while taking responsibility for handling propositional structure on its own. The intuition tactic also exhibits the helpful behavior of leaving for the user any subgoals that it could not establish. sep is meant to be an analogue of intuition for separation logic. We also want it to handle easy instantiation of existential quantifiers, since they appear so often in our specifications. We can divide the operation of sep into five main phases. We will sketch the workings of each phase separately.
3. Tactic Support The examples from the last two sections show how much of the gory details of proofs can be hidden from programmers. In actuality, every command triggers the addition of one or more proof obligations that cannot be discharged effectively by any of the built-in Coq automation tactics. Not only is it hard to prove the obligations, but it is also hard to infer the right intermediate specifications. Our separation logic formulas range well outside the propositional fragment that automated tools tend to handle; specification inference and proving must deal with higher-order features. Here is an example of the proof obligations generated for the code we gave earlier for the stack push method. Numbers prefixed with question marks are unification variables, whose values the sep tactic must infer.
3.1 Simple Constraint Solving It is trivial to determine the proper value for any unification variable appearing alone on one side of the implication. For instance, given the goal p --> x * q --> y ==> ?123
ls ~~ rep s ls ==> Exists v :@ option ptr, s --> v * ?200 v
we simply set ?123 to p --> x * q --> y. Given the slightly more complicated goal
forall v : option ptr, s --> v * ?200 v ==> ?192 v
p --> x * q --> y ==> ?123 x
?192 hd ==> ?217 * emp
we abstract over x in the premise to produce fun x’ => p --> x’ * q --> y.
forall v : ptr, ?217 * v --> Node x hd ==> ?206 v
3.2 Intermediate Constraint Solving When the trivial unification rules are not sufficient, we need to do more work. We introduce names for all existential quantifiers and computationally-irrelevant variables in the premise. For instance, starting with
?206 nd ==> ?234 * (Exists v’ :@ ?231, s --> v’) ?234 * s --> Some nd ==> ls ~~ rep s (x :: ls)
m ~~ Exists v :@ T, p --> v * rep m v ==> ?123 * Exists x :@ T, p --> x
We can see that each goal, compared to the previous goals, has at most one new unification variable standing for a specification; of the two new variables appearing in the second last line, one stands for a type, which will be easy to infer by standard unification, once the values of prior variables are known. Also, each new specification variable has its value determined by the value of the new variable from the previous goal. This is no accident; we designed our combinators and notations to have this property.
we introduce names to simplify the premise, leading to this goal: p --> v’ * rep m’ v’ ==> ?123 * Exists x :@ T, p --> x Now we run the user’s unfolder tactic, which might simplify some use of a definition. Let us assume that no such simplification
87
performed by the user’s solver tactic, until no further progress can be made. Finally, sep discharges all goals of the form P ==> P, by reflexivity. Every step of the overall process is implemented in Ltac, so that only a bug in Coq would allow sep to declare an untrue goal as true, no matter which customization the programmer provides. By construction, every step builds an explicit proof term, which can be validated afterward with an independent checker that is relatively simple, compared to the operation of all of the decision procedures that may have contributed to the proof.
occurs for this example. We notice that the points-to fact on the right mentions the same pointer as a fact on the left, so these two facts may be unified, implying x = v’. Canceling this known information, we are left with rep m’ v’ ==> ?123 which is resolvable almost trivially. We cannot give ?123 a value that mentions the variables m’ and v’, since we introduced them with elimination rules within our proof. These variables are not in scope at the point in the original program where the specification must be inserted. Instead, we remember how each local variable was introduced and re-quantify at the end, like this:
4. Evaluation
m ~~ Exists v :@ T, rep m v ==> ?123
We have used our environment to implement and verify several data structures, including the Stack and Queue examples that appeared in Section 2. We also follow the evaluation of our prior Ynot system in implementing a generic signature of imperative finite maps. We built three very different implementations: a trivial implementation based on pointers to heap-allocated functional association lists, an implementation based on binary search trees, and an implementation based on hash tables. Any of the implementations can be used interchangeably via ML-style functors, and their shared signature is phrased in terms of dependently-typed maps, where the type of data associated with a key is calculated from an arbitrary Coq function over that key. Our largest example, a packrat PEG parser (Ford 2004), uses these finite maps to cache intermediate results. We also verified one more exotic data structure: binomial trees, which are tree structures with a non-trivial rule for determining how many pointers are stored at each node. This data structure is often applied in implementing priority queues. Our implementation is interesting in its use of a dependently-typed recursive function to characterize functional models of such trees. Finally, we chose representative examples from two competing data structure verification systems, Smallfoot (Berdine et al. 2005) and Jahob (Zee et al. 2008), and reimplemented those examples in our new Ynot. Figure 5 presents code size statistics for our case studies. “Program” code is code that is preserved by extraction. “Specs” are the pre- and post-conditions of every function defined in the module. The core of a Ynot module consists of heap representation “rep” code (e.g., the definitions named rep in our examples), along with proofs (e.g., push listRep) and tactics (e.g., simp prem) dealing with these representations. The annotations column counts the number of lines of programmer specified annotations (e.g., ). The total overhead column sums proofs, tactics, and annotations. We also present type-checking and proving times (in minutes and seconds), as measured on a 2.8 GHz Pentium D with 1 GB of memory. So far, we have not optimized our tactics for running time; they are executed by direct interpretation of programs in a dynamicallytyped language.
Now the trivial unification is valid. The crucial part of this process was the matching of the two points-two facts. We have specialcase rules for matching conclusion facts under quantifiers, for conclusions that match the pre-conditions of the read, write, and free rules. Beyond that, we apply cancellation of identical terms on the two sides of the implication, when those terms do not fall under the scopes of quantifiers. These simple rules seem to serve well in practice. 3.3 Premise Simplification After specification inference, the next step is to simplify the premise of the implication. Any emp in the premise may be removed, and any lifted pure formula [φ] may be removed from the implication and added instead to the normal proof context. We also remove existential quantifiers and irrelevant variable unpackings in the same way as in the previous phase. 3.4 Conclusion Simplification The main sep loop is focused on dealing with parts of the conclusion. We remove occurrences of emp, and we remove any pure formula [φ] that the user’s solver tactic is able to prove. An existential formula Exists x :@ T, P(x) in the conclusion is replaced by P(?456), for a fresh unification variable ?456. When no more of these rules apply, we look for a pair of unifiable subformulas on the sides of the implication. All such pairs are unified and crossed out. This may determine the value of a variable introduced for an existential quantifier. For instance, say we begin with this goal. [m < 17] * p --> m ==> Exists x :@ nat, p --> x * [x < 42] Premise simplification would move the initial impure fact into the normal proof context, leaving us with this. p --> m ==> Exists x :@ nat, p --> x * [x < 42] Conclusion simplification would introduce a name for the existentially-bound variable.
Our previous version of Ynot placed a significant interactive proof burden on the programmer. The previous Ynot hash table, for instance, required around 320 explicit Coq tactic invocations. Each tactic invocation (indicated by a terminating “.” in Coq) represents a manual intervention by the Ynot programmer. These invocations tended to be low-level steps, like choosing which branch of a disjunction to prove. As such, these proofs are brittle in the face of minor changes. In some previous Ynot developments, the ratio of manual proof to program text is over 10 to 1. For comparison, a large scale compiler certification effort (Leroy 2006) has reported a proof-to-code ratio of roughly 6 to 1. In contrast, our new hash table requires only about 70 explicit tactic invocations. These invocations tend to be high level steps, like performing induction or invoking the sep tactic. We have
p --> m ==> p --> ?789 * [?789 < 42] Next, conclusion simplification would match the two p points-to facts, since their pointers unify trivially. emp ==> [m < 42] This goal can be reduced to emp ==> emp by using the normal proof context to deduce the fact inside the brackets. 3.5 Standard Coq Automation When sep has run out of rules to apply, the remaining subgoal is subjected to standard Coq automation. Propositional structure and calls to recursive functions are simplified where possible. sep ends by running a loop over those simplifications and the simplifications
88
Stack Queue Ref to Functional Finite Map Hash Table BST Finite Map Binomial Tree Association List Linked List Segments Packrat PEG Parser
Program 14 26 8 34 31 19 48 84 277
Specs 8 12 16 21 16 12 34 34 110
Rep 14 22 2 6 6 13 17 19 15
Proofs 7 41 2 70 22 0 41 91 102
Tactics 5 25 2 38 8 9 51 208 55
Annotations 0 0 0 34 4 7 10 7 5
Total Overhead 12 66 4 142 34 16 102 306 162
Time (m:s) 0:12 1:36 0:05 0:45 1:35 2:33 3:10 2:15 1:20
Figure 5. Breakdown of numbers of lines of different kinds of code in the case studies checking, where typing invariants may be specified with booleanvalued program functions and checked at runtime. This approach generally does not enable full static correctness verification. Partly as a way to support imperative programming in type theory, Swierstra and Altenkirch (2007) have studied pure functional semantics for effectful programming language features, with embeddings in Haskell and Agda. Chargu´eraud and Pottier (2008) have demonstrated a translation from a calculus of capabilities to a pure functional language. In each case, the authors stated plans to do traditional interactive verification on the pure functional models that they generate. Since such verification is generally done in logics without general recursion, these translations cannot be used to verify general recursive programs without introducing an extra syntactic layer, in contrast to Ynot. Each other approach also introduces restrictions on the shape of the heap, such as the absence of stored impure functions in the case of Swierstra and Altenkirch’s work. Other computer proof assistants are based around pure functional programming languages, with opportunities for encoding and verifying imperative programs. Nonetheless, we see the elegance of our approach as depending on the confluence of a number of features not found in other mature proof assistants. ACL2 (Kaufmann and Moore 1997) does not support higher-order logic or higherorder functional programming. Bulwahn et al. (2008) describe a system for encoding and verifying impure monadic programs in Isabelle/HOL. Their implementation does not support storing functions in the heap. They suggest several avenues for loosening this restriction, and the approaches that support heap storage of impure functions involve restricting attention to functions that are constructive or continuous (properties that hold of all Coq functions), necessitating some extra proof burden or syntactic encoding. There is closely related work in the field of shape analysis. The TVLA system (Sagiv et al. 2002) models heap shapes with a firstorder logic with a built-in transitive closure operation. With the right choices of predicates that may appear in inferred specifications, TVLA is able to verify automatically many programs that involve both heap shape reasoning and reasoning in particular decidable theories such as arithmetic. The Xisa system (Chang and Rival 2008) uses an approach similar to ours, as Xisa is based on user specification of inductive characterizations of shape invariants. Xisa builds this inductive definition mechanism into its framework, while we inherit a more general mechanism from Coq. Xisa is based on hardcoded algorithms for analyzing inductive definitions and determining when and how they should be unfolded. Such heuristics lack theoretical guarantees about how broadly they apply. In the design of our system, we recognize this barrier and allow users to extend the generic solver with custom rules for dealing with custom inductive predicates. In comparing the new Ynot environment to the above systems and all others that we are aware of, there are a number of common advantages. No other system supports both highly-automated
observed that such tactic-based proofs are significantly easier to maintain. We also made rough comparisons against two verification systems that do not support reasoning about first-class functions. The Jahob (Zee et al. 2008) system allows the specification and verification of recursive, linked data structures in a fragment of Java. We implemented an association list data structure that is included as an example in the Jahob distribution. Code-size-wise, the two implementations are quite similar. For instance, they both require around twenty lines of heap representation code, and they both require about a dozen lines of code for the lookup function’s loop invariant. Our Ynot implementation uses explicit framing conditions in places where Jahob does not, but we speculate that we can probably remove these annotations with additional, custom automation. Our second comparison is against the Smallfoot (Berdine et al. 2005) system, which does completely automated verification of memory safety via separation logic. We implemented Ynot versions of 10 linked list segment functions included with the Smallfoot distribution. In each case, the Ynot and Smallfoot versions differed by no more than a few lines of annotation burden.
5. Related Work Considering the two automated systems that we just mentioned, Smallfoot uses a very limited propositional logic, and Jahob uses an undecidable higher-order logic. Many interesting program specifications cannot be written in Smallfoot’s logic and cannot be proved to hold by Jahob’s automated prover. Neither of these systems supports higher-order programs, and neither supports customprogrammed proof procedures, for cases where standard automation is insufficient. The ESC/Java (Flanagan et al. 2002) and Spec# (Barnett et al. 2004) systems tackle some related problems within the classical verification framework. These systems have strictly less support for modeling data structures than Jahob has, so that it is impractical to use them to perform full verifications of many data structures. A number of systems have been proposed recently to support dependently-typed programming in a setting oriented more towards traditional software development than Coq is. Agda (Norell 2007) and Epigram (McBride and McKinna 2004) are designed to increase the convenience of programming in type theory over what Coq provides, but, out of the box, these systems support neither imperative programming nor custom proof automation. ATS (Chen and Xi 2005) includes novel means for dealing with imperative state, but it includes no proof automation beyond decision procedures for simple base theories like linear arithmetic. This makes it much harder to write verified data structure implementations than in Ynot. Concoqtion (Pasalic et al. 2007) allows the use of Coq for reasoning about segments of general OCaml programs. While those programs may use imperativity, the Coq reasoning is restricted to pure index terms. Sage (Gronski et al. 2006) supports hybrid type-
89
Lukas Bulwahn, Alexander Krauss, Florian Haftmann, Levent Erk¨ok, and John Matthews. Imperative functional programming with Isabelle/HOL. In Proc. TPHOLs, 2008.
proofs based on separation logic (when they work) and highly human-guided proofs (when they are needed), let alone combinations of the two. None of the systems with significant automation support the combination of imperative and higher-order features, like we handle in the example of our higher-order memoizer and iterators. We also find no automated systems that deal with dependent types in programs. The first of these advantages seems critical in the verification of imperative programs that would be difficult to prove correct even if refactored to be purely functional. For instance, it seems plausible that our environment could be used eventually to build a verified compiler that uses imperative data structures for efficient dataflow analysis, unification in type inference, and so on. None of the purely-automated tools that we have surveyed could be applied to that purpose without drastic redesign. We are not aware of any previous toolkit for manual proof about imperative programs in proof assistants that would make the task manageable; the manual reasoning about state would overwhelm “the interesting parts” of compiler verification.
Bor-Yuh Evan Chang and Xavier Rival. Relational inductive shape analysis. In Proc. POPL, 2008. Arthur Chargu´eraud and Franc¸ois Pottier. Functional translation of a calculus of capabilities. In Proc. ICFP, 2008. Chiyan Chen and Hongwei Xi. Combining programming with theorem proving. In Proc. ICFP, 2005. David Delahaye. A tactic language for the system Coq. In Proc. LPAR, 2000. Cormac Flanagan, K. Rustan M. Leino, Mark Lillibridge, Greg Nelson, James B. Saxe, and Raymie Stata. Extended static checking for Java. In Proc. PLDI, 2002. Bryan Ford. Parsing expression grammars: A recognition-based syntactic foundation. In Proc. POPL, 2004. Jessica Gronski, Kenneth Knowles, Aaron Tomb, Stephen N. Freund, and Cormac Flanagan. Sage: Hybrid checking for flexible specifications. In Proc. Scheme Workshop, 2006.
6. Conclusions & Future Work The latest Ynot source distribution, including examples, can be downloaded from the project web site:
Matt Kaufmann and J. S. Moore. An industrial strength theorem prover for a logic based on Common Lisp. IEEE Trans. Softw. Eng., 23(4), 1997.
http://ynot.cs.harvard.edu/ Concurrency is a big area for future work on Ynot. Systems like Smallfoot (Berdine et al. 2005) do automated separation-logic reasoning about memory safety of concurrent programs. We would like to extend that work to full correctness verification, by designing a monadic version of concurrent separation logic that fits well within Coq. The full potential of the Ynot approach also depends on explicit handling of other computational effects, such as exceptions and input-output. Our prior prototype handled the former, and ongoing work considers supporting the latter. As with any project in automated theorem proving, there is always room for improvements to automation and inference. A future version of Ynot could benefit greatly in usability by incorporating abstract interpretation to infer specifications, as several automated separation logic tools already do. Nonetheless, our current system already fills a crucial niche in the space of verification tools. We have presented the first tool that performs well empirically in allowing mixes of manual and highly-automated reasoning about heap-allocated data structures, as well as the first tool to provide aggressive automation in proofs of higher-order, imperative programs. We hope that this will form a significant step towards full functional verification of imperative programs with deep correctness theorems.
Xavier Leroy. Formal certification of a compiler back-end or: programming a compiler with a proof assistant. In Proc. POPL, 2006.
References
John C. Reynolds. Separation logic: A logic for shared mutable data structures. In Proc. LICS, 2002.
Barbara Liskov and Stephen N. Zilles. Specification techniques for data abstractions. IEEE Trans. Software Eng., 1(1):7–19, 1975. Conor McBride and James McKinna. The view from the left. J. Functional Programming, 14(1):69–111, 2004. Aleksandar Nanevski, Greg Morrisett, and Lars Birkedal. Polymorphism and separation in Hoare Type Theory. Proc. ICFP, 2006. Aleksandar Nanevski, Greg Morrisett, Avraham Shinnar, Paul Govereau, and Lars Birkedal. Ynot: Reasoning with the awkward squad. In Proc. ICFP, 2008. Ulf Norell. Towards a practical programming language based on dependent type theory. PhD thesis, Chalmers University of Technology, 2007. Emir Pasalic, Jeremy Siek, Walid Taha, and Seth Fogarty. Concoqtion: Indexed types now! In Proc. PEPM, 2007. Rasmus L. Petersen, Lars Birkedal, Aleksandar Nanevski, and Greg Morrisett. A realizability model for impredicative Hoare Type Theory. In Proc. ESOP, 2008.
Mike Barnett, K. Rustan M. Leino, and Wolfram Schulte. The Spec# programming system: An overview. In Proc. CASSIS, 2004.
Mooly Sagiv, Thomas Reps, and Reinhard Wilhelm. Parametric shape analysis via 3-valued logic. ACM TOPLAS, 24, 2002.
Bruno Barras and Bruno Bernardo. The Implicit Calculus of Constructions as a programming language with dependent types. In Proc. FoSSaCS, 2008.
Wouter Swierstra and Thorsten Altenkirch. Beauty in the beast: A functional semantics for the awkward squad. In Proc. Haskell Workshop, 2007.
Josh Berdine, Cristiano Calcagno, and Peter W. O’Hearn. Smallfoot: Modular automatic assertion checking with separation logic. In Proc. FMCO, 2005.
Karen Zee, Viktor Kuncak, and Martin Rinard. Full functional verification of linked data structures. In Proc. PLDI, 2008.
Yves Bertot and Pierre Cast´eran. Interactive Theorem Proving and Program Development. Coq’Art: The Calculus of Inductive Constructions. Texts in Theoretical Computer Science. Springer Verlag, 2004.
90
Experience Report: seL4 Formally Verifying a High-Performance Microkernel Gerwin Klein
Philip Derrin
Kevin Elphinstone
NICTA and University of NSW
[email protected]
NICTA
[email protected]
NICTA and University of NSW
[email protected]
Abstract
Isabelle/HOL
We report on our experience using Haskell as an executable specification language in the formal verification of the seL4 microkernel. The verification connects an abstract operational specification in the theorem prover Isabelle/HOL to a C implementation of the microkernel. We describe how this project differs from other efforts, and examine the effect of using Haskell in a large-scale formal verification. The kernel comprises 8,700 lines of C code; the verification more than 150,000 lines of proof script.
Abstract Specification
Executable Specification
Haskell Prototype
Automatic Translation
Categories and Subject Descriptors D.2.4 [Software Engineering]: Software/Program Verification; D.1.1 [Programming Techniques]: Functional Programming; D.4.5 [Operating Systems]: Reliability—Verification General Terms Keywords
1.
High-Performance C Implementation Refinement Proof
Figure 1. Specification layers in the L4.verified project.
Verification, Design, Languages
Haskell, seL4, microkernel, Isabelle/HOL
and made formal verification towards abstract and concrete levels substantially easier and faster than they would have been otherwise. The basic structure of the verification project is shown in Fig. 1. The left-hand side follows the classic pattern of a traditional refinement. There is an abstract specification at the top, an executable specification in the middle, and a C implementation on the bottom. Elkaduwe et al. (2008) have also created an even more abstract security model with security proof that would be placed above the abstract specification, but it has not yet been formally connected with the rest of the stack. In the setting of a commercial Common Criteria evaluation, the abstract specification is the high-level design and the executable specification is the low-level design. Cock et al. (2008) present details on the proof between abstract and executable level; the proof between executable specification and implementation will appear elsewhere (Winwood et al. 2009). While neither the main property (functional correctness) nor the main proof methodology (refinement) are unusual, the size and scope of the project are. The verification does not stop at a specification, but descends to the implementation level: 8,700 lines of high-performance, manually tuned C code close to hardware. All proofs in this project are machine-checked in the interactive theorem prover Isabelle/HOL (Nipkow et al. 2002). The project is also unusual in the approach it takes to kernel design and implementation. Two teams were involved: a kernel design team with an operating systems background, and a verification team with a formal methods background. The right-hand side of Fig. 1 indicates that the executable specification of seL4 is produced from a kernel prototype written in Haskell. We have implemented an automatic translator that converts our subset of Haskell into Isabelle/HOL. The Haskell prototype is written and maintained by the design team. It is the principal embodiment of their design decisions. It also became, after automatic translation, the starting point for the verification effort. The traditional model for greenfield projects is to work top-down from a high-level specification,
Introduction
We report on our experience using the functional programming language Haskell in the formal verification of the seL4 microkernel (Elphinstone et al. 2007). The seL4 kernel is an evolution of the high-performance L4 microkernel family (Liedtke 1995) for secure, embedded devices. It provides essential operating system services such as threads, inter-process communication, virtual memory, interrupts, and authorisation via capabilities. In earlier work (Derrin et al. 2006), we reported on our experience with Haskell as a specification language for seL4. In this paper, we concentrate on the effect our choice of Haskell had on the formal verification of the kernel, from abstract operational specification down to high-performance C code. To our knowledge this is the first largescale formal verification project that employs Haskell (or any other functional programming language) in this way.1 We found that working with Haskell decreased our kernel design time, enabled an iterative prototyping process in an area where usually only top-down and bottom-up approaches are advocated, 1 The
ACL2 prover uses LISP as its formal language. Our use of Haskell differs in the sense that our executable kernel prototype in Haskell is an independent program that can stand on its own without theorem prover involvement.
Permission to make digital or hard copies of all or part of this work for personal or classroom use is granted without fee provided that copies are not made or distributed for profit or commercial advantage and that copies bear this notice and the full citation on the first page. To copy otherwise, to republish, to post on servers or to redistribute to lists, requires prior specific permission and/or a fee. ICFP’09, August 31–September 2, 2009, Edinburgh, Scotland, UK. c 2009 ACM 978-1-60558-332-7/09/08. . . $5.00 Copyright
91
a large-scale functional program. Again, this was easily extended. We were able to implement all of the monad and list functions that we used of the Haskell library in Isabelle. Proving termination for every function was less difficult than we anticipated — for primitive recursion and for functions with a simple lexicographic termination measure the proofs are straightforward and in many cases entirely automatic. However, we avoided complex recursion patterns, such as nested mutual recursion, because they would have been more difficult to translate. This was also desirable because the microkernel has to operate on strictly limited and known stack depth, so recursion ultimately had to be implemented by loops in C anyway. We used only a constrained subset of Haskell that could be translated to Isabelle/HOL. Besides forgoing the use of laziness in any essential way, we made limited use of type classes in Haskell (in particular using only three specific instances of M ONAD m), and avoided most GHC extensions. The use of Haskell at this stage had two main effects on the verification component of the project. First, it removed the need to interpret vague and inaccurate natural language design specifications, user manuals, or incomprehensible optimised C code. Second, it constrained the design team to a subset of Haskell that could be handled by the automatic translation, leading them to instinctively favour designs suitable for formal specification. Since extending the subset had an obvious cost in terms of modifications to the translator, there was a natural counter-force to increasing this subset too much. The question How would we write this in Haskell?, and therefore How can it be formalised?, was a topic in design team discussions. In a normal kernel design process, it would not have been.
and for existing implementations to work bottom-up from the implementation level; beginning verification with an executable specification is unusual. The effects of this choice on the verification are discussed below. The abstract specification and the C implementation were developed manually; both were started before the design — and therefore the executable specification — was completely stable. Both activities fed changes back into the Haskell prototype, but they did not supersede it. The Haskell prototype remained the central reference model throughout the duration of the project.
2.
Executable and Abstract Specification
Microkernels in the L4 family share a number of basic design principles, set out by Liedtke (1995). They provide only the abstractions that are essential for performance or security — primarily virtual memory, threads, and inter-process communication. They are designed with an emphasis on IPC performance, which is critical to the overall performance of microkernel-based systems. They have, historically, provided only minimal support for managing kernel resources and for controlling access to communication. The seL4 project set out to design a new microkernel based on the same basic principles, but taking a new approach with capability-based resource management and access control. Since this entailed designing a new API that was significantly different to that of previous L4 kernels, there was a large design space to explore. We decided, at the outset, that implementing a new kernel in C from scratch when the design was still uncertain was a risky proposition; much time would be wasted on rewrites and low-level, hardware-dependent debugging before a final design emerged. On the other hand, designing a kernel formally on paper before implementing it might result in an API with limited application to realworld use. We wanted to be able to execute user-level programs to test our proposed designs. Also, since we intended to formally verify safety properties of the kernel (Tuch et al. 2005), we desired a precise specification with well-defined semantics. Thus, we developed an executable model of the design in Haskell. This model was gradually developed into a complete prototype, exploring various design alternatives in the process (see Derrin et al. 2006). We were able to exercise user-level programs on the binary level by attaching a processor and platform simulation to the Haskell prototype. At the same time, we formalised the Haskell prototype by translating it into Isabelle/HOL; the translation was initially performed by hand, but was later automated. When, after a number of iterations, the kernel design in Haskell had begun to stabilise, we constructed an abstract, operational specification with fewer data structure details, and with features like scheduling underspecified so that different implementation choices could be explored in later versions of the kernel. The abstract specification is meant to specify what the kernel does; the executable specification gives details on how it is done. This initial abstract formalisation process provided immediate feedback on correctness and safety to the design team. The feedback increased when we started the refinement proof between the two layers (see Sect. 4). Isabelle/HOL is based on lambda calculus, and can be seen as a functional programming language extended with logical operators. It is less expressive than Haskell in some ways: every function must terminate, which limits use of laziness; type classes cannot have multiple parameters, and there are no constructor type class, so there is no built-in Monad typeclass, nor was there initially dosyntax for lists of sequential operations. However, Isabelle’s syntax is easily extensible, and we were able to define our own do-syntax for the specific monads used by our abstract and executable models. Unsurprisingly, the Isabelle/HOL standard library is geared towards theorem proving and felt therefore limited for implementing
3.
High Performance C Implementation
The goals of the Haskell prototype were twofold: predictable behaviour to provide an easy path to formalisation, and enough detail to provide an easy path to a C implementation. The second requirement, especially, led to an imperative-style Haskell program with extensive use of the StateT and ErrorT monads, including an explicit model of kernel memory addressed by typed pointers. An explicit hardware interface made it easier to connect the prototype to different simulators (M5, qemu, and our own ARMv6 instruction simulator). This interface also became the machine interface of the C kernel. As performance tuning is essential for microkernels, we did not attempt to generate C code from the model, but implemented the kernel manually, following the structure of the executable specification closely. The direct C implementation work was roughly 2 person months in effort, which is insignificant compared to the 20 person years spent on the complete project. The extremely rapid manual implementation was possible thanks to the precise executable specification. Not many implementation choices had to be made, and the structure of the program was clearly laid out already. D. Wheeler’s SLOCCount estimates that the effort for implementing the kernel directly in C would have been 4 person years. The effort for designing, writing and documenting the Haskell prototype was ca 2 person years. Based on this estimate, the use of Haskell reduced the implementation effort by 50%. In the first implementation pass, we did not pay any attention to performance. The result of this initial pass was therefore unsurprisingly slow (on the order of the Mach microkernel), a factor of 3 slower than comparable operations in existing L4 kernels. After a first round of manual optimisations, seL4 IPC performance is now comparable to OKL4 2.1 (2008) on ARMv6. Another consequence of using a functional language as the design source was the structured use of tagged unions in the C code. Verification of unstructured unions in C is unpleasant. Since
92
unions and structs were used in a principled way, we managed to avoid this additional verification burden entirely. Moreover, we did not trust the compiler to translate C bitfields correctly and with the fine-grained control we required; instead, we generated the C code for these structures and tagged unions automatically from a separate specification language (Cock 2008). We also generated the corresponding Isabelle/HOL proof of code correctness. Our verification framework treats a large, true subset of C99. The main restrictions are: we do not allow the address-of (&) operator on local variables, because the stack is modelled separately from the heap; we do not allow function pointers and goto statements; we make some expressions such as x++ statement forms; and we allow at most one side-effecting sub-expression in any assignment, because execution order is arbitrary otherwise. The function pointer prohibition implies that we did not make heavy use of higher-order functions in Haskell apart from some specific functions that are used to emulate C control structures such as mapM , zipWithM, catchError, and so on. This prohibition could be lifted fairly easily from the C translation, but we found it advantageous to strive for simplicity over features when possible in our Hoare logic framework for C. We translate C types precisely into Isabelle/HOL, including pointers, address arithmetic, finite integers, structs, and padding in structs (Tuch et al. 2007). Our target architecture is ARMv6; the compiler is GCC 4.2.2. Strictly speaking, Fig. 1 is inaccurate in that we do not reason on C directly, but on a translation of C into Isabelle/HOL. In contrast to the Haskell/Isabelle translation, this is a comparatively small translation step, with explicit care taken to map the semantics of C precisely into the theorem prover. Although our experience was in general favourable, efficient kernel code does not always translate well from Haskell. For example, the executable model’s error handling code contains a function that loads a message into a user-level context (setMRs), which is applied to the results of one of several functions that generate messages (as lists of machine words) from various error types. Translating this directly to C leads to implicit allocation of memory to temporarily hold the message, and double copying of the message’s contents; in order to keep the C code efficient, we manually unfolded the definition of setMRs and fused it with the message generation functions before translating. More generally, we found that Haskell at times encouraged coding practices that are inefficient in our subset of C if translated naively: passing large structures as function arguments, throwing and catching exceptions for error handling, and function composition that depends on laziness to be efficient. An interesting observation on the C implementation was that the C program was in parts less verbose than the explicit memory model we used in Haskell, because load-check-modify-store idioms are simply written as pointer accesses and updates in C. The more verbose style for this part in Haskell did not hinder verification. On the contrary, pointers made up a large part of the hidden complexity of the C program that was dealt with explicitly in the executable and abstract models. The additional verbosity was local only. In total, the Haskell prototype comes to 5,700 LOC compared to 8,700 LOC in C (numbers according to SLOCCount). To summarise, we restricted our use of Haskell to a suitable subset and were able to manually implement a high-performance C version of the kernel in very little time. The Haskell and C versions have almost identical data and code structures. We exploited this fact heavily in the verification.
4.
implementation. Our embedding of Haskell into Isabelle is shallow, the embedding of C into Isabelle is deep for statements and shallow for expressions. The main statement we proved in each of the two steps is formal refinement, reduced to forward simulation: if the initial states are in a system-global state relation R, and the concrete level takes one step, then the abstract level must be able to take a corresponding step such that the resulting states again are in the relation R. Cock et al. (2008) extend this classic notion to state monads, integrating the aspects of failure, non-determinism and exceptions needed in the kernel specifications. The notion implies, and we have proved in Isabelle/HOL, that all Hoare triples that are true on the abstract level are also true on the concrete level, modulo the state relation R. 4.1
First Refinement Step
Refinement step one in the verification took ca 8 person years in total and manually produced 117,000 lines of Isabelle/HOL proof script. This step contains the conceptually interesting part of the proof, reasoning about the design aspects of execution safety and correctness. We cannot go into the details of this proof here for space reasons, but the simpler and higher-level data structures of the abstract specification require invariants on their more detailed counterparts on the executable level to show correspondence. Basic preconditions of the correspondence proof are that each operation is well defined, that memory accesses are correctly typed, that assertions do not fail, that objects that are read from do exist, and that partially defined functions (e.g. those with incomplete patterns in Haskell) are used only within their domain. These preconditions for safe execution spawned a number of complex invariants on how the kernel works, how it explicitly re-uses memory, and how it prevents dangling references to deleted objects in any part of the kernel (including all of memory). Reasoning on this level included explicit decoding of binary system call arguments read from user registers and full argument checking to ensure safe operation for any kernel input, be it benign, maliciously crafted, or simply garbage. The effect our use of Haskell had on this proof can be summarised as: the ability to exploit structural similarities, an increased use of library functions, initial increased technical friction in working with generated definitions, and different proof style. We explain each of these in more detail in the following paragraphs. The Haskell prototype existed first, and therefore the abstract specification was inspired by it in structure. We were able to exploit this structural similarity to make the proof easier. Being inspired by the executable level also means that our abstract specification is probably more concrete than it may have been without this input. A higher-level abstract specification would have meant more distance for the refinement proof to the executable level, but possibly less distance to further layers above. For showing specific properties of the kernel that turn out to be too complex for the complete abstract operational specification, we would add a further, more abstract layer to the stack that is specialised to the property under consideration, such as we are currently exploring with the abstract access control model of seL4 (Elkaduwe et al. 2008). Haskell being a fully-featured programming language led the design team to make more extensive use of library functions like mapM and zipWithM than they otherwise may have. At the time, there were no Isabelle versions of these functions. Introducing them saved verification work because we avoided repeating proofs over many similar recursion patterns. On the less positive side, we observed more technical friction in the proofs that were concerned with definitions generated from Haskell than in those that were written in Isabelle directly. This was expected. Programming idioms did not always match up with how rules were phrased in the Isabelle library. The executable
Formal Verification
As mentioned in the introduction, the formal verification of seL4 consisted of two major refinement steps: between abstract and executable specification, and between executable specification and
93
4.2
specification was generated Isabelle code that was not as concise as the Haskell source, and not always nice to read. This turned out to be an initial problem only. The new idioms became manageable once the verification team were used to them, and had built up a library of matching rules. The generated code could often be rewritten trivially with associativity and other simple, general statemonad laws to read more nicely. Due to the monadic, imperative style of the Haskell prototype and therefore the executable specification, the majority of the proof took the form of Hoare triples, weakest precondition reasoning, and correspondence calculus reasoning. Apart from the rewrites mentioned above we used only little of the algebraic reasoning that would usually be associated with verifying functional programs. This proof structure is mainly an artefact of the application area and of having C as a target implementation language. We did use induction where recursion was involved in the Haskell program. In C, these were replaced by loops. This first proof lead to around 200 changes in the Haskell prototype and 300 changes in the abstract specification. Less than half of these were genuine bugs or design defects. Most changes were for proof convenience: reshuffling functions to match up more closely, adding assertions to transport information across levels, and adding local checks or re-arranging code to make properties more obviously true. The majority of the bugs we found during verification were mundane: simple typos and some copy & paste errors. We did also find more subtle problems in the initial design like missing argument checking, potential security violations etc. Of course, it does not matter if the defect is mundane or not: the kernel will happily crash or allow a security attack either way. It is interesting to note that the actual discovery of defects does not necessarily occur when they leap from the screen in the form of a counter example or unprovable lemma (although that did happen). Instead, many defects were found when new invariants became necessary and the verification and design teams discussed what these should be, whether they would hold, and, if so, why. One answer a verification team should be wary of is this is never done anywhere in the kernel. This answer usually means proving a new fact about the whole kernel instead of quick local reasoning. It is not clear if the use of Haskell would have been beneficial in just the proof of the first refinement step in isolation. Nicer, more elegant reasoning might have been possible in a more abstract, still executable setting with definitions written directly in Isabelle/HOL. The detailed executable model, and its use as a prototype running user binaries, injected a sometimes unpleasant dose of realism into the proof — forcing us to consider implementation details that are necessary for an efficient kernel, rather than one that simply functions correctly. For example, one part of the first refinement proof that was particularly challenging was the relation between the abstract and executable models’ versions of the capability derivation tree. This is one of the two main metadata structures in the seL4 implementation; it is conceptually a forest of directed trees, and is represented in the abstract model by simple functions that encode a binary relation between pointers to the nodes. The executable model represents it the way a real kernel implementation would: as a set of doubly linked lists, each corresponding to a pre-order traversal of a tree. The depth information is implicitly encoded in the nodes, and is available only by comparing two nodes; the depth comparison function requires that its arguments are in the stored order. Furthermore, the lists are not represented by Haskell’s standard list type, but by pairs of pointers stored in separate node objects in the modelled physical memory — as they would be in C. Naturally, the invariants that must be maintained by operations on these structures are complex, and therefore so is the refinement proof for those operations. This realism paid off in the second refinement step.
Second Refinement Step
One important observation about the first refinement step is that we spent roughly 80% of the proof effort on showing invariants of the abstract and executable levels and only 20% on the correspondence itself. The invariants were necessary preconditions for the correspondence, but they also carry a large amount of information on how precisely the kernel works and why its internal data structures are safe to use. Because the Haskell and C implementations share almost identical data and code structures, we were able to avoid these 80% for the second step. The important invariants had already been proved on the executable specification level, and no complex semantic reasoning was necessary on the C level. The most complex new relationships we had to show on the C level were the implementation of Haskell or Isabelle lists as doubly linked lists, some of which were encoded in existing data structures. The C verification did lead us to prove new invariants on the level of the executable specification, but far fewer than we needed in the first step. They were mainly due to optimisations in C that made use of conditions known to be true over kernel execution. The main challenges in the second step were dealing with C language semantics and data structure encodings, but without complex data refinement. Having to prove at the same time that the C code maintains complex invariants as we had shown in step one would have made this proof much harder. At the time of writing, the proof on the C level is completed for 474 functions out of 518 and we have so far spent 26 person months on this part of the verification. The speed of verification on this level was 3–4 functions per person per week with 3–5 persons working on this body of proofs concurrently. Even though the kernel implementation had in the meantime been used in a number of small student projects, had been ported to the x86 architecture, and had been run through static analysis tools, we still found 97 defects in C during the verification. We had not attempted to test the implementation in great detail, because formal verification was scheduled anyway. For each of the defects we could have found a test case that demonstrated it, but of course the question is whether we would have thought of these beforehand. Unsurprisingly, the defects were concentrated in parts of the kernel that were less used in the student projects and that were complicated to use. Most of them were simple translation errors and typos in the implementation step from Haskell to C, fewer were defects in new data encodings and optimisation. We also observed compiler specific errors: for instance, some functions that we had annotated with GCC’s pure and const attributes to enable optimisations were not in fact pure or const. The compiler did not check the attributes, and neither did we initially in the verification. This lead to unexpected execution behaviour in otherwise already verified code. We have updated the verification framework in the meantime to include such compiler hints and make them proof obligations. The pure and const attributes are now checked automatically. As in the first refinement step, it was crucial for the verification that we were able to change the C code as well as the Haskell source for proof convenience instead of having to prove complex reordering theorems. For some optimisations, we changed the observable behaviour of both the abstract and the executable specifications. For instance, we changed the order in which data was stored in global data structures, or the order in which arguments were checked (and therefore which error messages would be reported first). In summary, the verification of seL4 proceeded in two main steps. Step one dealt with mostly semantic content in a shallow embedding; step two was more syntactic and dealt with C, its memory model and specific optimisations. We were able to avoid a large part of the proof in the second step, because of the structural similarity between the C and the Haskell implementations.
94
5.
Acknowledgments
Conclusion
We thank Timothy Bourke, Michael Norrish, and Thomas Sewell for reading and discussing drafts of this article.
We have presented our experience in using Haskell in the verification of the seL4 microkernel. The aspects of the verification that are specific to this project are its size, the implementation level it descends to, and its iterative development cycle. As there is not sufficient space to survey related work in this article we refer to the comprehensive overview by Klein (2009). We consider it important for the success of this project that the kernel was designed by a team with an OS background, not by the verification team. The verification team believes it would have designed a much more elegant, but much more useless microkernel. The connection between the two teams was the Haskell prototype. All of the verification and implementation activities fed back into this central reference specification of the project. We could have created the executable specification in Isabelle directly, but that would have left the design team out of the loop. We could also have chosen another functional language, such as ML, rather than Haskell; the primary motivation for our choice was the local availability of experienced Haskell programmers at UNSW, where Haskell is used in several research projects and was used as the introductory undergraduate programming language at the time. In addition, we consider the extensive tool-chain support for Haskell (compiler, foreign function interface, literate Haskell) an important contributor to the success of the Haskell source as a simultaneous binary-compatible prototype, design document, and formal executable specification. The part of the OS team that actively wrote the Haskell code had previous experience with Haskell. The part of the OS team that did not have extensive experience with Haskell was comfortable with the new language after less than one month. The culture shock between the Formal Methods and Operating Systems groups was smaller than expected and greatly alleviated by team members who had gone through advanced courses in both areas. Will this approach work for everything? We believe that for high assurance on the level of kernel and systems code where performance and hardware interaction are important, the approach will work well. On the application level, it might be sufficient to stop at the level of an executable specification, possibly in Haskell or ML, if the compiler, runtime, and translation into Isabelle can be trusted.
NICTA is funded by the Australian Government as represented by the Department of Broadband, Communications and the Digital Economy and the Australian Research Council through the ICT Centre of Excellence program.
References D. Cock. Bitfields and tagged unions in C: Verification through automatic generation. In B. Beckert and G. Klein, editors, Proceedings of the 5th International Verification Workshop (VERIFY’08), volume 372 of CEUR Workshop Proceedings, pages 44–55, Sydney, Australia, Aug 2008. D. Cock, G. Klein, and T. Sewell. Secure microkernels, state monads and scalable refinement. In O. A. Mohamed, C. Mu˜noz, and S. Tahar, editors, 21st TPHOLs, volume 5170 of LNCS, pages 167–182, Montreal, Canada, Aug 2008. Springer. P. Derrin, K. Elphinstone, G. Klein, D. Cock, and M. M. T. Chakravarty. Running the manual: An approach to high-assurance microkernel development. In ACM SIGPLAN Haskell WS, Portland, OR, USA, Sep 2006. D. Elkaduwe, G. Klein, and K. Elphinstone. Verified protection model of the seL4 microkernel. In J. Woodcock and N. Shankar, editors, VSTTE 2008 — Verified Softw.: Theories, Tools & Experiments, volume 5295 of LNCS, pages 99–114, Toronto, Canada, 2008. Springer. K. Elphinstone, G. Klein, P. Derrin, T. Roscoe, and G. Heiser. Towards a practical, verified kernel. In 11th HotOS, pages 117–122, 2007. G. Klein. Operating system verification — an overview. S¯adhan¯a, 34(1): 27–69, Feb 2009. J. Liedtke. On µ-kernel construction. In 15th SOSP, pages 237–250, Copper Mountain, CO, USA, Dec 1995. T. Nipkow, L. Paulson, and M. Wenzel. Isabelle/HOL — A Proof Assistant for Higher-Order Logic, volume 2283 of LNCS. Springer, 2002. Open Kernel Labs. OKL4 v2.1. http://www.ok-labs.com, 2008. H. Tuch, G. Klein, and G. Heiser. OS verification — now! In 10th HotOS, pages 7–12, Santa Fe, NM, USA, Jun 2005. USENIX. H. Tuch, G. Klein, and M. Norrish. Types, bytes, and separation logic. In M. Hofmann and M. Felleisen, editors, 34th POPL, pages 97–108, 2007. S. Winwood, G. Klein, T. Sewell, J. Andronick, D. Cock, and M. Norrish. Mind the gap: A verification framework for low-level C. In S. Berghofer, T. Nipkow, C. Urban, and M. Wenzel, editors, Proc. 22nd International Conference on Theorem Proving in Higher Order Logics (TPHOLs’09), volume 5674 of LNCS. Springer, 2009. To appear.
95
Biorthogonality, Step-Indexing and Compiler Correctness Nick Benton
Chung-Kil Hur
Microsoft Research
[email protected]
University of Cambridge
[email protected]
Abstract
to a deeper semantic one (‘does the observable behaviour of the code satisfy this desirable property?’). In previous work (Benton 2006; Benton and Zarfaty 2007; Benton and Tabareau 2009), we have looked at establishing type-safety in the latter, more semantic, sense. Our key notion is that a high-level type translates to a lowlevel specification that should be satisfied by any code compiled from a source language phrase of that type. These specifications are inherently relational, in the usual style of PER semantics, capturing the meaning of a type A as a predicate on low-level heaps, values or code fragments together with a notion of A-equality thereon. These relations express what it means for a source-level abstractions (e.g. functions of type A → B) to be respected by low-level code (e.g. ‘taking A-equal arguments to B-equal results’). A crucial property of our low-level specifications is that they are defined in terms of the behaviour of low-level programs; making no reference to any intensional details of the code produced by a particular compiler or the grammar of the source language. Of course, the specifications do involve low-level details of data representations and calling conventions – these are part of the interface to compiled code – but up to that, code from any source that behaves sufficiently like code generated by the compiler should meet the specification, and this should be independently verifiable. Ideally, one might wish to establish the sense in which a compilation scheme is fully abstract, meaning that the compiled versions of two source phrases of some type are in the low-level relation interpreting that type iff the original source phrases are contextually equivalent. If low-level specifications are used for checking linking between the results of compiling open programs and code from elsewhere1 and full abstraction does not hold, then source level abstractions become ‘leaky’: reasoning about equivalence or encapsulation at the source level does not generally translate to the target, which can lead to unsound program transformations in optimizing compilers and to security vulnerabilities (Abadi 1998; Kennedy 2006). Ahmed and Blume (2008) also argue that fully abstract translation should be the goal, and prove full abstraction for (source to source) typed closure conversion for a polymorphic lambda calculus with recursive and existential types. Later on we will say something about why we believe that ‘full’ full abstraction may not, in practice, be quite the right goal, but we certainly do want ‘sufficiently abstract’ compilation, i.e. the preservation of the reasoning principles that we actually use in an optimizing compiler or in proving security properties. The low-level relations of our previous work are not, however, really even sufficiently abstract, having roughly comparable power to a denotational semantics in continuation-passing style (CPS). This is a very strong and useful constraint on the behaviour of machine-code programs, but does not suffice to prove all the equa-
We define logical relations between the denotational semantics of a simply typed functional language with recursion and the operational behaviour of low-level programs in a variant SECD machine. The relations, which are defined using biorthogonality and stepindexing, capture what it means for a piece of low-level code to implement a mathematical, domain-theoretic function and are used to prove correctness of a simple compiler. The results have been formalized in the Coq proof assistant. Categories and Subject Descriptors F.3.1 [Logics and Meanings of Programs]: Specifying and Verifying and Reasoning about Programs—Mechanical verification, Specification techniques; F.3.2 [Logics and Meanings of Programs]: Semantics of Programming Languages—Denotational semantics, Operational semantics; F.3.3 [Logics and Meanings of Programs]: Studies of Program Constructs—Type structure,Functional constructs; D.3.4 [Programming Languages]: Processors—Compilers; D.2.4 [Software Engineering]: Software / Program Verification—Correctness proofs, Formal methods General Terms
Languages, theory, verification
Keywords Compiler verification, denotational semantics, biorthogonality, step-indexing, proof assistants
1.
Introduction
Proofs of compiler correctness have been studied for over forty years (McCarthy and Painter 1967; Dave 2003) and have recently been the subject of renewed attention, firstly because of increased interest in security and certification in a networked world and secondly because of advances in verification technology, both theoretical (e.g. separation logic, step-indexed logical relations) and practical (e.g. developments in model checking and improvements in interactive proof assistants). There are many notions of correctness or safety that one might wish to establish of a compiler. For applying language-based techniques in operating systems design, as in proof-carrying code, one is primarily interesting in broad properties such as type-safety, memory-safety or resource-boundedness. Although these terms are widely used, they are subject to a range of interpretations. For example, type-safety sometimes refers to a simple syntactic notion (‘is the generated code typable using these rules?’) and sometimes
Permission to make digital or hard copies of all or part of this work for personal or classroom use is granted without fee provided that copies are not made or distributed for profit or commercial advantage and that copies bear this notice and the full citation on the first page. To copy otherwise, to republish, to post on servers or to redistribute to lists, requires prior specific permission and/or a fee. ICFP’09, August 31–September 2, 2009, Edinburgh, Scotland, UK. c 2009 ACM 978-1-60558-332-7/09/08. . . $5.00 Copyright
1 This
is obviously important for foreign function interfaces and multilanguage interoperability, but can be an issue even for separate compilation using the same compiler. It also covers the simpler and ubiquitous case of handcrafted implementations of standard library routines.
97
yield complete systems. The plugging −◦− : P ×C → S might be effected by appending bits of program, substituting terms, applying continuations to arguments or composing processes in parallel. In any such situation, there is contravariant map (·)⊥ : P(P) → P(C) given by
tions we might like between low-level programs: even something as simple as the commutativity of addition does not hold for arbitrary integer computations (just as it doesn’t in a lambda calculus with control), even though it does in our pure source language. To understand how to refine our low-level relations further, it is natural to look at logical relations between low-level code and elements of the domains arising in a standard, direct-style, denotational model of our language, which is what we’ll do here. Given such a typed relation between high-level semantics and low-level programs, a low-level notion of typed equivalence can be generated by considering pairs of low-level programs that are related to some common high-level denotational value. The relations we define will establish a full functional correctness theorem for a simple compiler, not merely a semantic type safety theorem. Just as for type-safety, there are several approaches to formulating such correctness theorems in the literature. A common one, used for example by Leroy (2006), is to define an operational semantics for both high- and low-level languages and then establish a simulation (or bisimulation) result between source programs and their compiled versions, allowing one to conclude that if a closed high-level program terminates with a particular observable result, then its compiled version terminates with the same result, and often the converse too (Hardin et al. 1998; Leroy and Grall 2009). The limitation of these simulation-based theorems is that they are not as compositional (modular) or extensional (behavioural) as we would like, in that they only talk about the behaviour of compiled code in contexts that come from the same compiler, and usually specify a fairly close correspondence between the (non-observable) intermediate states of the source and target. We would rather have maximally permissive specifications that capture the full range of pieces of low-level code that, up to observations, behave like, or realize, a particular piece of high-level program. A slogan here is that ‘the ends justify the means’: we wish to allow low-level programs that extensionally get the right answers, whilst intensionally using any means necessary. The main results here will involve logical relations between a cpo-based denotational semantics of a standard CBV lambda calculus with recursion and programs in an extended SECD-like target, chosen to be sufficiently low-level to be interesting, yet simple enough that the important ideas are not lost in detail. The relations will involve both biorthogonality and step-indexing, and in the next section we briefly discuss these constructions in general terms, before turning to the particular use we make of them. The results in the paper have been formalized and proved in the Coq proof assistant, building on a formalization of domain theory and denotational semantics that we describe elsewhere (Benton et al. 2009). The scripts are available from the authors’ webpages.
2.
Orthogonality and Step-Indexing
2.1
Biorthogonality
P ⊥ = {c ∈ C | ∀p ∈ P, p ◦ c ∈ O} and a homonymous one in the other direction, (·)⊥ : P(C) → P(P) C ⊥ = {p ∈ P | ∀c ∈ C, p ◦ c ∈ O} yielding a contravariant Galois connection, so that, amongst many other things, (·)⊥⊥ is a closure operator (inflationary and idempotent) on P(P), and that any set of the form C ⊥ is (·)⊥⊥ -closed.2 The binary version of this construction starts with a binary relation (e.g. an equivalence relation) on S and proceeds in the obvious way. For compiler correctness, we want the interpretations of sourcelevel types or, indeed, source-level values, to be compositional and extensional: properties of low-level program fragments that we can check independently and that make statements about the observable behavior of the complete configurations that arise when we plug the fragments into ‘appropriate’ contexts. This set of ‘appropriate’ contexts can be thought of as a set of tests: a low-level fragment is in the interpretation of a source type, or correctly represents a source value, just when it passes all these tests. Thus these low-level interpretations will naturally be (·)⊥⊥ -closed sets. For a simply typed source language, we can define these sets by induction on types, either positively, starting with an over-intensional set and then taking its (·)⊥⊥ -closure, or negatively, by first giving an inductive definition of a set of contexts and then taking its orthogonal. See Vouillon and Melli`es (2004) for more on the use of biorthogonality in giving semantics to types. 2.2
Step-Indexing
Logical predicates and relations can be used with many different styles of semantics. When dealing with languages with recursion and working denotationally, however, we often need extra admissibility properties, such as closure under limits of ω-chains. Operational logical relations for languages with recursion generally need to satisfy some analogue of admissibility. One such that has often been used with operational semantics based on lambda terms (Plotkin 1977; Pitts and Stark 1998) considers replacing the recursion construct rec f x = M (or fixpoint combinator) with a family of finite approximations: recn f x = M for n ∈ N, unfolds the recursive function n times in M and thereafter diverges. Appel and McAllester (2001) introduced step-indexed logical relations, which have since been refined and succesfully applied by various authors to operational reasoning problems for both high and low level languages, many of which involve challenging language features (Ahmed 2006; Appel et al. 2007; Benton and Tabareau 2009; Ahmed et al. 2009). Step-indexing works with small-step operational semantics and N-indexed sets of values, with (n, v) ∈ P (or v ∈ Pn ) meaning ‘value v has property P for n steps of reduction’. An interesting feature of step-indexing is that one usually works directly with this family of approximants; the limits that one feels are being approximated (like {v | ∀n, v ∈ Pn }) do not play much of a rˆole.
Biorthogonality is a powerful and rather general idea that has been widely used in diffent kinds of semantics in recent years, beginning with the work of Pitts and Stark (1998) and of Krivine (1994). One way of understanding the basic idea is as a way of ‘contextualizing’ properties of parts of a system, making them compositional and behavioural. In the unary case, we start with some set of systems S (e.g. configurations of some machine, lambda terms, denotations of programs, processes) and some predicate O ⊆ S, which we call an observation, over these systems (e.g. those that execute without error, those that terminate, those that diverge). Then there is some way of combining, or plugging, program fragments p ∈ P in whose properties we are interested (bits of machine code, terms, denotations of terms, processes) with complementary contexts c ∈ C (frame stacks, terms with holes, continuations, other processes) to
2.3
On Using Both
Amongst the useful properties of the operational (·)⊥⊥ -closure operation used by Pitts and Stark (1998) is that it yields admissible 2 Pitts and Stark, and some other non-Gallic authors, tend to write (·)> rather than (·)⊥ .
98
3.
relations (in the recn sense). The same is true of its natural denotational analogue (Abadi 2000). Our earlier work on low-level interpretations of high-level types (Benton and Tabareau 2009) used both step-indexing and orthogonality, but there was some question as to whether the step-indexing was really necessary. Maybe our closed sets are automatically already appropriately ‘admissible’, just by construction, and there is no need to add extra explicit indexing? Slightly to our surprise, it turns out that there are good reasons to use both (·)⊥⊥ -closure and step-indexing, for which we now try to give some intuition. The aim is to carve out interpretations of high-level types and values as ‘well-behaved’ subsets of low-level, untyped programs. The essence of these interpretations generally only depends upon these well-behaved subsets: we’ll (roughly) interpret a function type A → B as the set of programs that when combined with a good argument of type A and a good continuation expecting something of type B, yield good behaviour. So exactly what range of impure, potentially type-abstraction violating, operations are available in the untyped language (the range of ‘any means necessary’ above) does not seem to affect the definitions or key results. Programs that use low-level features in improper ways will simply not be in the interpretations of high-level entities, and nothing more needs to be said. For a simply-typed total language without recursion, this intuition is quite correct: a Krivine-style realizability interpretation is essentially unaffected by adding extra operations to the untyped target. Even though orthogonality introduces quantification over bigger sets of contexts, nothing relies explicitly on properties of the untyped language as a whole. In the presence of recursion, the situation changes. The fact that Pitts and Stark’s (·)⊥⊥ -closed relations are admissible depends on a ‘compactness of evaluation’ result, sometimes called an ‘unwinding theorem’, saying that any complete program p terminates iff there is some n such that for all m ≥ n, p with all the recs replaced by recm s terminates, which is clearly a global property of all untyped programs. In the denotational case, attention is already restricted to operations that can be modelled by continuous functions, i.e. ones that behave well with respect to approximation, in the chosen domains. But realistic targets often support operations that can violate these global properties. Examples of such egregiously non-functional operations include the ‘reflection’ features of some high-level languages (such as Java or C]) or, more interestingly, the ability of machine code programs to switch on code pointers or read executable machine instructions.3 We have found that the presence of such seriously non-functional operations does not just make the proofs harder, but can actually make the ‘natural’ theorems false. Appendix A shows how the addition of equality testing on lambda terms to an untyped lambda calculus breaks a ‘standard’ syntactic interpretation of simple types as sets of untyped terms in the presence of term-level recursion in the source. Fortunately, as we will see, step-indexing sidesteps this problem. In place of appealing to a global property that holds of all untyped programs, we build a notion of approximation, and the requirement to behave well with respect to it, directly into the definition of our logical relations. In fact, we will also do something very similar on the denotational side, closing up explicitly under limits of chains.
Source Language
Our high-level language PCFv is a conventional simply-typed, callby-value functional language with types built from integers and booleans by products and function spaces, with type contexts Γ defined in the usual way: t Γ
:= :=
Int | Bool | t → t0 | t × t0 x1 : t 1 , . . . , x n : t n
We separate syntactic values (canonical forms), ranged over by V , from general expressions, ranged over by M and restrict the syntax to ANF, with explicit sequencing of evaluation by let and explicit inclusion of values into expressions by [·]. The typing rules for values and for expressions are shown in Figure 1. Note that there are really two forms of judgement, but we refrain from distinguishing them syntactically. The symbol ? stands for an arbitrary integer-valued binary operation on integers, whilst > is a representative boolean-valued one. PCFv has the obvious CBV operational semantics, which we elide here, and a conventional, and computationally adequate, denotational semantics in the category of ω-cpos and continuous maps, which we now briefly summarize to fix notation. Types and environments are interpreted as cpos: def
=
N
JIntK
def
JBoolK
=
B
Jt → t K
def
=
JtK ⇒ Jt0 K⊥
Jx1 : t1 , . . . , xn : tn K
def
Jt1 K × · · · × Jtn K
0
=
where ⇒ is the cpo of continuous functions and × is the Cartesian product cpo. Typing judgements for values and expressions are then interpreted as continuous maps: JΓ ` V : tK JΓ ` M : tK
: :
JΓK → JtK JΓK → JtK⊥
defined by induction. So, for example JΓ ` Fix f x = M : A → BK ρ = µdf .λdx ∈ JAK.JΓ, f : A → B, x : A ` M : BK (ρ, df , dx ) We write [·] : D → D⊥ for the unit of the lift monad. We elide the full details of the denotational semantics as they are essentially the same as those found in any textbook, such as that of Winskel (1993). The details can also be found, along with discussion of the Coq formalization of the semantics, in Benton et al. (2009).
4.
Target Language and Compilation
4.1
An SECD Machine
The low-level target is a variant of an SECD virtual machine (Landin 1964). We have chosen such a machine rather than a lowerlevel assembly language, such as that of our previous work, so as to keep the formal development less cluttered with detail. But we are emphatically not interested in the SECD machine as something that is inherently “for” executing programs in a language like our source. We have included an equality test instruction, Eq, that works on arbitrary values, including closures, so a counterexample to a naive semantics of types like that in Appendix A can be constructed. Furthermore, the logical relations we present have been carefully constructed with applicability to lower-level machines in mind.
3 Real
implementations, even of functional languages, can make non-trivial use of such features. For example, interpreting machine instructions that would normally be executed in order to advance to a safe-point for interruption, building various runtime maps keyed on return addresses, doing emulation, JIT-compilation or SFI.
99
Values: [T V AR]
Γ, x : t ` x : t [T F IX]
[T BOOL]
Γ ` b : Bool
Γ, f : t → t0 , x : t ` M : t0 Γ ` Fix f x = M : t → t0
(b ∈ B)
[T P ]
[T IN T ]
Γ ` n : Int
(n ∈ N)
Γ ` Vi : ti (i = 1, 2) Γ ` hV1 , V2 i : t1 × t2
Expressions: [T V AL]
[T AP P ]
Γ`V :t Γ ` [V ] : t
[T LET ]
Γ ` V1 : t → t0 Γ ` V2 : t Γ ` V1 V2 : t0
[T OP ]
Γ ` M : t Γ, x : t ` N : t0 Γ ` let x = M in N : t0
[T IF ]
Γ ` V1 : Int Γ ` V2 : Int Γ ` V1 ? V2 : Int [T F ST, T SN D]
Γ ` V : Bool Γ ` M1 : t Γ ` M2 : t Γ ` if V then M1 else M2 : t
[T GT ]
Γ ` V1 : Int Γ ` V2 : Int Γ ` V1 > V2 : Bool
Γ ` V : t1 × t2 Γ ` πi (V ) : ti (i = 1, 2)
Figure 1. Typing rules for PCFv The inductive type Instruction, ranged over by i, is defined by i
:=
4.2
Swap | Dup | PushV n | Op ? | PushC c | PushRC c | App | Ret | Sel (c1 , c2 ) | Join | MkPair | Fst | Snd | Eq
where c ranges over Code, the set of lists of instructions, n ranges over integers, and ? over binary operations on integers. The set Value of runtime values, ranged over by v is defined by v
5.
where e ranges over Env, defined to be list Value. So a Value is either an integer literal, a closure containing an environment and some code, a recursive closure, or a pair values. We also define Stack Dump CESD
= = =
list Value list (Code × Env × Stack) Code × Env × Stack × Dump
CESD is the set of configurations of our virtual machine. A configuration hc, e, s, di comprises the code to be executed, c, the current environment, e, an evaluation stack s and a call stack, or dump, d. The deterministic one-step transition relation 7→ between configurations is defined in Figure 2. There are many configurations with no successor, such as those in which the next instruction is Swap but the the stack depth is less than two; we say such configurations are stuck or terminated. (So there is no a priori distinction between normal and abnormal termination.) We write cesd 7→k to mean that the configuration cesd takes at least k steps without getting stuck, and say it diverges, written cesd 7→ω if it can always take a step:
(c, s) o n hc0 , e0 , s0 , d0 i = hc ++ c0 , e0 , s ++ s0 , d0 i We also have an operation · ^ · that appends an element of Env onto the environment component of a configuration:
def
cesd 7→ω ⇐⇒ (∀k, cesd 7→k ) Conversely, we say cesd terminates, and write cesd 7→∗ does not diverge: cesd 7→∗
Logical Relations
In this section we define logical relations between components of the low-level SECD machine and elements of semantic domains, with the intention of capturing just when a piece of low-level code realizes a semantic object. In fact, there will be two relations, one defining when a low-level component approximates a domain element, and one saying when a domain element approximates a lowlevel component. These roughly correspond to the soundness and adequacy theorems that one normally proves to show a correspondence between an operational and denotational semantics, but are rather more complex. Following the general pattern of biorthogonality sketched above, we work with (predicates on) substructures of complete configurations. On the SECD machine side, complete configurations are elements of CESD, whilst the substructures will be elements of Value and of Comp, which is defined to be Code × Stack. If v : Value then we define vb : Comp to be ([], [v]), the computation comprising an empty instruction sequence and a singleton stack with v on. Similarly, if c : Code then b c : Comp is (c, []). The basic plugging operation on the low-level side is · o n ·, taking an element of Comp, the computation under test, and an element of CESD, thought of as a context, and combining them to yield a configuration in CESD:
n | CL (e, c) | RCL (e, c) | PR (v1 , v2 )
:=
Compiling PCFv to SECD
The compiler comprises two mutually-inductive functions mapping (typed) PCFv values and expressions into Code. We overload L· M for both of these functions, whose definitions are shown in Figure 3.
e ^ hc0 , e0 , s0 , d0 i = hc0 , e ++ e0 , s0 , d0 i 5.1
, if it
Approximating Denotational By Operational
The logical relation expressing what it means for low-level computations to approximate denotational values works with step-indexed entities. We write iValue for N × Value, iComp for N × Comp and
def
⇐⇒ ¬(cesd 7→ω ).
100
hSwap :: c, e, v1 :: v2 :: s, di hDup :: c, e, v :: s, di hPushV n :: c, [v1 , . . . , vk ], s, di hPushN n :: c, e, s, di hPushC bod :: c, e, s, di hPushRC bod :: c, e, s, di hApp :: c, e, v :: CL (e0 , bod ) :: s, di hApp :: c, e, v :: RCL (e0 , bod ) :: s, di hOp ? :: c, e, n2 :: n1 :: s, di hRet :: c, e, v :: s, (c0 , e0 , s0 ) :: di hSel (c1 , c2 ) :: c, e, v :: s, di hSel (c1 , c2 ) :: c, e, 0 :: s, di hJoin :: c, e, s, (c0 , e0 , s0 ) :: di hMkPair :: c, e, v1 :: v2 :: s, di hFst :: c, e, PR (v1 , v2 ) :: s, di hSnd :: c, e, PR (v1 , v2 ) :: s, di hEq :: c, e, v1 :: v2 :: s, di hEq :: c, e, v1 :: v2 :: s, di
7→ 7 → 7→ 7 → 7 → 7 → 7→ 7→ 7→ 7→ 7→ 7→ 7→ 7→ 7 → 7 → 7→ 7→
hc, e, v2 :: v1 :: s, di hc, e, v :: v :: s, di hc, [v1 , . . . , vk ], vn :: s, di hc, e, n :: s, di hc, e, CL (e, bod ) :: s, di hc, e, RCL (e, bod ) :: s, di hbod , v :: e0 , [], (c, e, s) :: di hbod , v :: RCL (e0 , bod ) :: e0 , [], (c, e, s) :: di hc, e, n1 ? n2 :: s, di hc0 , e0 , v :: s0 , di hc1 , e, s, (c, [], []) :: di (if v 6= 0) hc2 , e, s, (c, [], []) :: di hc0 , e, s, di hc, e, PR (v2 , v1 ) :: s, di hc, e, v1 :: s, di hc, e, v2 :: s, di hc, e, 1 :: s, di (if v1 = v2 ) hc, e, 0 :: s, di (if v1 6= v2 )
Figure 2. Operational Semantics of SECD Machine Values: Lx1 : t1 , . . . , xn : tn ` xi : ti M LΓ ` true : BoolM LΓ ` false : BoolM LΓ ` n : IntM LΓ ` hV1 , V2 i : t1 × t2 M Expressions:
= = = = =
LΓ ` Fix f x = M : t → t0 M LΓ ` [V ] : tM
=
0
=
0
= = = =
LΓ ` let x = M in N : t M
LΓ ` V1 V2 : t M LΓ ` if V then M1 else M2 : tM LΓ ` V1 ? V2 : IntM LΓ ` V1 > V2 : BoolM
=
[PushV i] [PushN 1] [PushN 0] [PushN n] LΓ ` V1 : t1 M ++LΓ ` V2 : t2 M ++[MkPair]
[PushRC (LΓ, f : t → t0 , x : t ` M : t0 M ++[Ret])]
LΓ ` V : tM
[PushC (LΓ, x : t ` N : t0 M ++[Ret])] ++LΓ ` M : tM ++[App]
LΓ ` V1 : t → t0 M ++LΓ ` V2 : tM ++[App] LΓ ` V : BoolM ++[Sel ((LΓ ` M1 : tM ++[Join]), (LΓ ` M2 : tM ++[Join])) LΓ ` V1 : IntM ++LΓ ` V2 : IntM ++[Op ?] LΓ ` V1 : IntM ++LΓ ` V2 : IntM ++[Op (λ(n1 , n2 ).n1 > n2 ⊃ 1 | 0)] Figure 3. Compiler for PCFv
iCESD for N × CESD and define an Env-parameterized observation O e over pairs of indexed computations from iComp and indexed contexts from iCESD:
rations. And each observation gives rise to two, closely related, Galois connections: one between predicates on (indexed) values and predicates on (indexed) contexts, and the other between those on (indexed) computations and (indexed) contexts. So there are four contravariant maps associated with each e : Env. Our definitions use these two of them:
def
O e (i, comp) (j, cesd ) ⇐⇒ (comp o n (e ^ cesd )) 7→min(i,j) So, given an environment e, O e holds of an indexed computation and an indexed context just when the configuration that results from appending the environment e and the code and stack from the computation onto the corresponding components of the context steps for at least the minimum of the indices of the iComp and the iCESD. We also define an observation on pairs of indexed values and indexed contexts by lifting values to computations:
↓e (·) ↓e (P ) ⇑e (·) ⇑e (Q)
: = : =
P(iValue) → P(iCESD) {jcesd | ∀iv ∈ P, O e iv jcesd } P(iCESD) → P(iComp) {icomp | ∀jcesd ∈ Q, O e icomp jcesd }
To explain the notation: down arrows translate positive predicates (over values and computations) into negative ones (over contexts), whilst up arrows go the other way. We use single arrows for the operations relating to values and double arrows for those relating to computations.
def
O e (i, v) (j, cesd ) ⇐⇒ O e (i, vb) (j, cesd ) Now we follow the general pattern of orthogonality, but with some small twists. We actually have a collection of observations, indexed by environments, made over step-indexed components of configu-
101
Now we can start relating the low-level machine to the highlevel semantics. If D is a cpo and RD i ⊆ Value×D is a N-indexed relation between machine values and elements of D then define the indexed relation [RD]n ⊆ Value × D⊥ by
For computations in context, we define EΓ,t ⊆ Comp × (JΓK ⇒ i JtK⊥ ) using the monadic lifting again
[RD]n = {(v, dv) | ∃d ∈ D, [d] = dv ∧ (v, d) ∈ RD n }
These relations are antimonotonic in the step indices and monotone in the domain-theoretic order (we also switch to infix notation for relations at this point):
C
= (EΓ → (Et )⊥ )i EΓ,t i
If, furthermore, S is a cpo and RS i ⊆ Env × S an indexed relation between machine environments and elements of S, then we define an indexed relation
Lemma 1. 1. If v Eti d, d v d0 and j ≤ i then v Etj d0 . 0 Γ 0 2. If e EΓ i ρ, ρ v ρ and j ≤ i then e Ej ρ . Γ,t 3. If comp Ei f , f v f 0 and j ≤ i then comp EΓ,t f 0. j
C
(RS → RD ⊥ )i ⊆ Comp × (S ⇒ D⊥ ) = {(comp, df ) | ∀k ≤ i, ∀(e, de) ∈ RS k , (k, comp) ∈⇑e (↓e ({(j, v) | (v, df de) ∈ [RD]j }))} which one should see as the relational action of the lift monad, relative to a relation on environments. The definition looks rather complex, but the broad shape of the definition is ‘logical’: relating machine computations to denotational continuous maps just when RS -related environments yield [RD]-related results. Then there is a little extra complication caused by threading the step indices around, but this is also of a standard form: computations are in the relation at i when they take k-related arguments to k-related results for all k ≤ i. Finally, we use biorthogonality to close up on the right hand side of the arrow; rather than making an intensional direct-style definition that the computation ‘yields’ a value v that is related to the denotational application df de , we take the set of all such related results, flip it across to an orthogonal set of contexts with ↓e (·) and then take that back to a set of computations with ⇑e (·). A special case of indexed relations between machine environments and denotational values is that for the empty environment. We define Ii ⊆ Env × 1, where 1 is the one-point cpo, by Ii = {([], ∗)}. We can now define the ‘real’ indexed logical relation of approximation between machine values and domain elements
The non-indexed versions of the approximation relations are then given by universally quantifying over the indices.
=
{(n, n) | n ∈ N}
=
{(0, false)} ∪ {(n + 1, true) | n ∈ N}
0 Eit×t
=
{(PR (v1 , v2 ), (dv1 , dv2 )) | (v1 , dv1 ) ∈ Eti ∧
def
∀i, v Eti d
e Γ ρ
⇐⇒
def
∀i, e EΓ i ρ
comp Γ,t df
⇐⇒
def
df ∀i, comp EΓ,t i
Lemma 2. If v t d then vb [],t (λ∗ : 1.[d]). 5.2
Approximating Operational By Denotational
Our second logical relation captures what it means for a denotational value to be ‘less than or equal to’ a machine computation. This way around we will again use biorthogonality, but this time with respect to the observation of termination. This is intuively reasonable, as showing that the operational behaviour of a program is at least as defined as some domain element will generally involve showing that reductions terminate. We will not use operational step-indexing to define the relation this way around, but an explicit admissible closure operation will play a similar rˆole. For e ∈ Env, comp ∈ Comp and cesd ∈ CESD our termination observation is defined by
where t is a type and i is a natural number index like this: EBool i
⇐⇒
Note that the relation on computations extends that on values:
Eti ⊆ Value × JtK
EInt i
v t d
def
n (e ^ cesd )) 7→∗ T e comp cesd ⇐⇒ (comp o which we again lift to values v ∈ Value: def
T e v cesd ⇐⇒ T e vb cesd
0
(v2 , dv2 ) ∈ Eti } 0
Eit→t
=
and again the observations generate two e-parameterized Galois connections, one between predicates on values and predicates on contexts, and the other between predicates on computations and predicates on contexts. Once more we use two of the four maps:
{(f, df ) | ∀k ≤ i, ∀(v, dv) ∈ Etk , C
0
(([App], [v, f ]), λ∗ : 1.df dv) ∈ (I → (Et )⊥ )k } This says that machine integers approximate the corresponding denotational ones, the machine zero approximates the denotational ‘false’ value, and all non-zero machine integers approximate the denotational ‘true’ value, reflecting the way in which the lowlevel conditional branch instruction works and the way in which we compile source-level booleans. Pair values on the machine approximate denotational pairs pointwise. As usual, the interesting case is that for functions. The definition says that a machine value f and a semantic function df are related at type t → t0 if whenever v is related to dv at type t, the computation whose code part is a single application instruction and whose stack part is the list [v, f ] is related to the constantly (df dv) function, of type 1 ⇒ Jt0 K⊥ , by the monadic lifting of the approximation relation at type t0 . Having defined the relation for values, we lift it to environments in the usual pointwise fashion. If Γ is x1 : t1 , . . . , xn : tn then EΓ i ⊆ Env × JΓK is given by
↓e (·) e
↓ (P )
:
P(Value) → P(CESD)
=
{cesd | ∀v ∈ P, T e v cesd }
⇑e (·)
:
P(CESD) → P(Comp)
⇑e (Q)
=
{comp | ∀cesd ∈ Q, T e comp cesd }
to define a relational action for the lift monad. If S and D are cpos, RS ⊆ Env × S and RD ⊆ Value × D, then define B
(RS → RD ⊥ ) ⊆ Comp × (S ⇒ D⊥ ) = {(comp, df ) | ∀(e, de) ∈ RS , ∀d, [d] = (df de) =⇒ comp ∈ ⇑e (↓e ({v | (v, d) ∈ RD}))} which follows a similar pattern to our earlier definition, in using biorthogonality on the right hand side of the arrow: starting with all values related to d, flipping that over to the set of contexts that terminate when plugged into any of those values and then coming back to the set of all computations that terminate when plugged into any of those contexts.
t
l EΓ i = {([v1 , . . . , vn ], (d1 , . . . , dn )) | ∀l, (vl , dl ) ∈ Ei }
102
apply a transitive closure operation to get a low-level notion of equivalence ∼: def Γ ` comp ∼ comp : t ⇐⇒ Γ ` comp ∼+ comp : t
Now we can define the second logical relation t
D ⊆ Value × JtK
by induction on the type t:
1
Int
=
{(n, n) | n ∈ N}
DBool
=
{(0, false)} ∪ {(n + 1, true) | n ∈ N}
=
{(PR (v1 , v2 ), (dv1 , dv2 )) | (v1 , dv1 ) ∈ Dt ∧
D D
t×t0
0
(v2 , dv2 ) ∈ Dt } Dt→t
0
{(f, df ) | ∀(v, dv) ∈ Dt ,
=
B
0
(([App], [v, f ]), λ∗ : 1.df dv) ∈ (I → (Dt )⊥ )} This relation also lifts pointwise to environments. For Γ = x1 : t1 , . . . , xn : tn define DΓ ⊆ Env × JΓK by DΓ = {([v1 , . . . , vn ], (dv1 , . . . , dvn )) | ∀l, (vl , dvl ) ∈ Dtl }
and then for computations in context, DΓ,t ⊆ (JΓK ⇒ JtK⊥ ) is given by B
DΓ,t = (DΓ → (Dt )⊥ ) The D relations are all down-closed on the denotational side: Lemma 3. 1. If v Dt d and d0 v d then v Dt d0 2. If e DΓ ρ and ρ0 v ρ then e DΓ ρ0 3. If comp DΓ,t f and f 0 v f then comp DΓ,t f 0 However, for a fixed v, it turns out that {d | v Dt d} is not always closed under taking limits of chains (see Appendix B for a more detailed explanation), which would prevent our compiler correctness proof going through in the case of recursion. We solve this problem by taking the Scott-closure. The closed subsets of a cpo D are those that are both down-closed and closed under limits of ω-chains. The closure Clos(P ) of a subset P ⊆ D is the smallest closed subset of D containing P . So we define def
t
v dv
⇐⇒
Γ
e de
def
⇐⇒
de ∈ Clos({d | e D d})
Γ,t
def
df ∈ Clos({d | comp DΓ,t d})
comp
df
⇐⇒
Lemma 5 (Adequacy for bottom). For any c ∈ Code and type t, if c |=[],t (λ∗ : 1.⊥JtK ) then c diverges unconditionally.
t
dv ∈ Clos({d | v D d})
For ground type observations, we say a computation comp converges to a particular integer value n if plugging it into an arbitrary context equiterminates with plugging n into that context:
Γ
n cesd 7→∗ =⇒ comp o n cesd 7→∗ ∀cesd , n bo ω ∧n bo n cesd 7→ =⇒ comp o n cesd 7→ω . And we can then show that if a piece of code realizes a non-bottom element [n] of JIntK⊥ in the empty environment, then it converges to n:
Realizability and Equivalence
Lemma 6 (Ground termination adequacy). For any c ∈ Code, if
Having defined the two logical relations, we can clearly put them together to define relations expressing that a machine value, environment or computation realizes a domain element: v t d ∧ v t d
def
e Γ de ∧ e Γ de
def
comp Γ,t df ∧ comp Γ,t df.
e |=Γ de
⇐⇒
comp |=Γ,t df
⇐⇒
c |=[],t (λ∗ : 1.[n]) then b c converges to n.
def
⇐⇒
Adequacy also holds for observation at the boolean type, with a definition of convergence to a value b that quantifies over those test contexts cesd that terminate or diverge uniformly for all machine values representing b. We finally show the compositionality of our realizability semantics.
and these relations naturally induce typed relations between machine components. We just give the case for computations
def
⇐⇒
Lemma 7 (Compositionality for application). For any cf, cx ∈ Code and df ∈ JΓK ⇒ (JtK ⇒ Jt0 K⊥ )⊥ , dx ∈ JΓK ⇒ JtK⊥ , if
Γ ` comp 1 ∼ comp 2 : t ∃df ∈ (JΓK ⇒ JtK⊥ ), comp 1 |=Γ,t df ∧ comp 2 |=Γ,t df
0
cf |=Γ,t→t df ∧ cx |=Γ,t dx
and we overload this notation to apply to simple pieces of code too:
def
2
The following says that if a piece of code realizes the denotational bottom value at any type, then it diverges unconditionally:
Lemma 4. If v t d then vb [],t (λ∗ : 1.[d]).
v |=t d
1
∀cesd , (b co n cesd ) 7→ω
Again, the relation for computations extends that for values:
5.3
2
So now we have a notion of what it means for a piece of SECD machine code to be in the semantic interpretation of a source language type, and what it means for two pieces of code to be equal when considered at that type. Clearly, we are going to use this to say something about the correctness of our compilation scheme, but note that the details of just what code the compiler produces have not really shown up at all in the definitions of our logical relations: it is only the interfaces to compiled code – the way in which integers, booleans and pairs are encoded and the way in which function values are tested via application – that are mentioned in the logical relation. In fact, equivalence classes of the ∼ relations can be seen as defining a perfectly good compositional ‘denotational’ semantics for the source language in their own right. A feature of this semantics is that there are no statements about what (non-observable) intermediate configurations should look like. For example, we never say that when a function is entered with a call stack that looks like ‘x’, then eventually one reaches a configuration that looks like ‘y’. At the end of the day, all we ever talk about is termination and divergence of complete configurations, which we need to connect with the intended behaviour of closed programs of ground type; this we do by considering a range of possible external test contexts, playing the role of top-level continuations. If c ∈ Code then we say c diverges unconditionally if
then
Γ ` c1 ∼ c2 : t ⇐⇒ Γ ` cb1 ∼ cb2 : t
cf ++ cx ++[App]
There seems no general reason to expect that the ∼ relations are transitive (though we have no concrete counterexample), so we
|=Γ,t
0
λ de : JΓK. (df de) g (dx de)
where g denotes the lifted (Kleisli) application.
103
6.
Applications
6.2.1
In this section, we illustrate the kind of results one can establish using the logical relations of the previous section. 6.1
Example: Commutativity of addition
Define the following source term plussrc = (Fix f x = [Fix g y = x + y])
Compiler Correctness
and its compiled code
Our motivating application was establishing the functional correctness of the compiler that we presented earlier.
pluscode(Γ) = LΓ ` plussrc : Int → Int → IntM.
Now we can show the following:
Theorem 1.
Lemma 8. For any Γ, for any c1 , c2 ∈ Code, and dc1 , dc2 ∈ JΓK ⇒ JIntK⊥ , if
1. For all Γ,V ,t, if Γ ` V : t then LΓ ` V : tM Γ,t [ JΓ ` V : tK ]
c1 |=Γ,Int dc1
2. For all Γ,M ,t, if Γ ` M : t then
and
c2 |=Γ,Int dc2
then
LΓ ` M : tM Γ,t JΓ ` M : tK
Γ ` APP(APP(pluscode Γ, c1 ), c2 ) ∼ APP(APP(pluscode Γ, c2 ), c1 ) : Int
The two parts are proved simultaneously by induction on typing derivations, as in most logical relations proofs. In the case for recursive functions, there is a nested induction over the step indices.
In other words, for any code fragments c1 , c2 that are in the interpretation of the source language type Int, manually composing those fragments with the code produced by the compiler for the curried addition function in either order yields equivalent behaviour of type Int.
Theorem 2. 1. For all Γ,V ,t, if Γ ` V : t then LΓ ` V : tM Γ,t [ JΓ ` V : tK ]
6.2.2
2. For all Γ,M ,t, if Γ ` M : t then
Example: First projection
Define the source term
LΓ ` M : tM Γ,t JΓ ` M : tK
projfstsrc = (Fix f x = [Fix g y = x])
This is another simultaneous induction on typing derivations. This time, the proof for recursive functions involves showing that each of the domain elements in the chain whose limit is the denotation is in the relation and then concluding that the fixpoint is in the relation by admissibility.
and the compiled code
then
Corollary 1.
projfstcode(Γ , t, t 0 ) = LΓ ` projfstsrc : t → t0 → tM
Lemma 9. For any Γ, t,t, for any c1 , c2 ∈ Code and dc1 ∈ JΓK ⇒ JtK⊥ and dc2 ∈ JΓK ⇒ Jt0 K⊥ , if
1. For all Γ,V ,t, if Γ ` V : t then LΓ ` V : tM |=Γ,t [ JΓ ` V : tK ]
c1 |=Γ,t dc1
2. For all Γ,M ,t, if Γ ` M : t then
and
0
c2 |=Γ,t dc2
and furthermore
LΓ ` M : tM |=Γ,t JΓ ` M : tK
∀de ∈ JΓK, ∃dv ∈ Jt0 K (dc2 de) = [dv]
So the compiled code of a well-typed term always realizes the denotational semantics of that term. A consequence is that compiled code for whole programs has the correct operational behaviour according to the denotational semantics:
which says that the code c2 realizes some total denotational computation of type t0 in context Γ, then
Corollary 2. For any M with [] ` M : Int,
This says that the compiled version of projfstsrc behaves like the first projection, provided that the second argument does not diverge.
Γ ` APP(APP(projfstcode(Γ, t, t0 ), c1 ), c2 ) ∼ c1 : t.
• If J[] ` M : IntK = ⊥ then L[] ` M : IntM diverges uncondi-
tionally.
• If J[] ` M : IntK = [n] then L[] ` M : IntM converges to n.
6.2.3
which follows by the adequacy lemmas above. And of course, by composing with the result that the denotational semantics is adequate with respect to the operational semantics of the source language, one obtains another corollary, that the operational semantics of complete source programs agrees with that of their compiled versions. It is this last corollary that is normally thought of as compiler correctness, but it is Corollary 1 that is really interesting, as it is that which allows us to reason about the combination of compiled code with code from elsewhere. 6.2
Our last example is slightly more involved, and makes interesting use of the non-functional equality test in the target language. We start by compiling the identity function on integers idsrc idcode(Γ)
Fix id x = x LΓ ` idsrc : Int → IntM
appnsrc = fun f => letrec apf n = fun v => if n > 0 then f (apf (n-1) v) else v
In this section we give some simple examples of typed equivalences one can prove on low-level code. We first define some macros for composing SECD programs. If c, cf, cx ∈ Code then define = =
= =
and then define a higher-order function appnsrc that takes a function f from integers to integers, an iteration count n and an integer v, and returns f applied n times to v. We present the definition in ML-like syntax rather than our ANF language to aid readability:
Low-level Equational Reasoning
LAMBDA(c) APP(cf, cx)
Example: Optimizing iteration
[PushC (c ++[Ret])] cf ++ cx ++[App]
104
abstraction with respect to the source language, but we might well wish to allow it.4 The case against full abstraction becomes stronger when one considers that one of our aims is to facilitate semantically type safe linking of code produced from different programming languages. There is a fair degree of ‘wiggle room’ in deciding just how strong the semantic assumptions and guarantees should be across these interfaces. They should be strong enough to support sound and useful reasoning from the point of view of one language, but not insist on what one might almost think of as ‘accidental’ properties of one language that might be hard to interpret or ensure from the point of view of others. The Coq formalization of these results was pleasantly straightforward. Formalizing the SECD machine, compiler, logical relations and examples, including the compiler correctness theorem, took a little over 4000 lines, not including the library for domain theory and the semantics of the source language. The extra burden of mechanized proof seems fairly reasonable in this case, and the results, both about the general theory and the examples, are sufficiently delicate that our confidence in purely paper proofs would be less than complete. There are many obvious avenues for future work, including the treatment of richer source languages and type systems, and of lower-level target languages. We intend particularly to look at source languages with references and polymorphism and at a target machine like the idealized assembly language of our previous work. We would also like to give low-level specifications that are more independent of the source language - the current work doesn’t mention source language terms, but does still talk about particular cpos. We would like to express essentially the same constraints in a more machine-oriented relational Hoare logic which might be more language neutral and better suited for independent verification. It would also be interesting to look at type systems that are more explicitly aimed at ensuring secure information flow.
and we let appncode(Γ) = LΓ ` appnsrc : (Int → Int) → Int → Int → IntM Now we define a handcrafted optimized version, appnoptcode(Γ) in the SECD language, which would, if one could write it, correspond to ML-like source code looking something like this: fun f => fun n => fun v => if f =α idcode(Γ) then v else appnsrc f n v The optimized code checks to see if it has been passed the literal closure corresponding to the identity function, and if so simply returns v without doing any iteration. We are then able to show that for any Γ, Γ ` appnoptcode(Γ) ∼ appncode(Γ) : (Int → Int) → Int → Int showing that the optimized version, which could not be written in the source language, is equivalent to the unoptimized original.
7.
Discussion
We have given a realizability relation between the domains used to model a simply-typed functional language with recursion and the low level code of an SECD machine with non-functional features. This relation was used to establish a semantic compiler correctness result and to justify typed equational reasoning on handcrafted low-level code. The relations make novel use of biorthogonality and step-indexing, and the work sheds interesting new light on the interaction of these two useful constructions. As we said in the introduction, there are many other compiler correctness proofs in the literature, but they tend not to be so compositional or semantic in character as the present one. The classic work on the VLISP verified Scheme compiler by Guttman et al. (1995) is very close in character, being based on relating a denotational semantics to, ultimately, real machine code. The untyped PreScheme language treated in that work is significantly more realistic than the toy language of the present work, though the proofs were not mechanized. The denotational semantics used there was in CPS and the main emphasis was on the behaviour of complete programs. Chlipala (2007) has also used Coq to formalize a correctness relation between a high-level functional language and low-level code, though in that case the source language is total and so can be given an elementary semantics in Sets. For ML-like languages (pure or impure), contextual equivalence at higher types is highly complex, depending subtly on exactly what primitives one allows. This is why, as we said in the introduction, we feel that fully abstract compilation might not be quite the right thing to aim for. For example, Longley (1999) shows that there are extensionally pure (and even useful) functionals that are only defineable in the presence of impure features, such as references. Adding such functionals is not conservative – they refine contextual equivalence at order four and above in pure CBV languages – yet it seems clear that they will be implementable in many low-level machines. Complicating one’s specifications to rule out these exotic programs in pursuit of full abstraction is not obviously worthwhile: it seems implausible that any compiler transformations or information flow policies will be sensitive to the difference. As another example, the presence of strong reflective facilities at the lowlevel, such as being able to read machine code instructions, might well make parallel-or defineable; this would obviously break full
Acknowledgments Thanks to Andrew Kennedy, Neel Krishnaswami, Nicolas Tabareau and Carsten Varming for many useful discussions. Extra thanks to Andrew and Carsten for their work on the Coq formalization of the source language and its denotational semantics.
References M. Abadi. >>-closed relations and admissibility. Mathematical Structures in Computer Science, 10(3), 2000. M. Abadi. Protection in programming-language translations. In 25th International Colloquium on Automata, Languages and Programming (ICALP), volume 1443 of Lecture Notes in Computer Science, 1998. A. Ahmed. Step-indexed syntactic logical relations for recursive and quantified types. In 15th European Symposium on Programming (ESOP), volume 3924 of Lecture Notes in Computer Science, 2006. A. Ahmed and M. Blume. Typed closure conversion preserves observational equivalence. In 13th ACM SIGPLAN International Conference on Functional Programming (ICFP), 2008. A. Ahmed, D. Dreyer, and A. Rossberg. State-dependent representation independence. In 36th ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages (POPL), 2009. A. Appel and D. McAllester. An indexed model of recursive types for foundational proof-carrying code. ACM Transactions on Programming Languages and Systems (TOPLAS), 23(5), 2001. A.W. Appel, P.-A. Melli`es, C.D. Richards, and J. Vouillon. A Very Modal Model of a Modern, Major, General Type System. 34th ACM SIGPLAN4 Discussing
the sense in which these two candidate full abstractionbreaking operations are incompatible would take us too far afield, however.
105
guments and returns true if the two (possibly functional) terms are syntactically equal and otherwise returns false.
SIGACT Symposium on Principles of Programming Languages (POPL), 2007. N. Benton. Abstracting allocation: The new new thing. In 20th International Workshop on Computer Science Logic (CSL), volume 4207 of LNCS, 2006.
Definition of VULE. As an example of a target language, we consider the call-by-value untyped lambda calculus with a syntactic equality test (VULE). We give the formal definition and operational semantics of VULE below. We fix a countable set V of variables. The set of values Val and the set of terms Term with variables in V are mutually inductively defined by the following rule:
N. Benton and N. Tabareau. Compiling functional types to relational specifications for low level imperative code. In 4th ACM SIGPLAN Workshop on Types in Language Design and Implementation (TLDI), 2009. N. Benton and U. Zarfaty. Formalizing and verifying semantic type soundness of a simple compiler. In 9th ACM SIGPLAN International Symposium on Principles and Practice of Declarative Programming (PPDP), 2007.
Val
:= |
x λx. t
Term
:= | | |
v ts u ≡α v ERROR
N. Benton, A. Kennedy, and C. Varming. Some domain theory and denotational semantics in Coq. In 22nd International Conference on Theorem Proving in Higher Order Logics (TPHOLs), volume 5674 of Lecture Notes in Computer Science, 2009.
where x ∈ V, u, v ∈ Val, t, s ∈ Term. As usual, we assume that the appplication is left-associative. FVar(t) for any term t denotes the set of free variables in t, and t [x 7→ s] the capture-avoiding substitution of the term s for the variable x in the term t. We also define some syntactic sugar for boolean operation and recursion (implemented via a CBV fixed point combinator).
A. Chlipala. A certified type-preserving compiler from lambda calculus to assembly language. In ACM SIGPLAN 2007 Conference on Programming Language Design and Implementation (PLDI), 2007. M. Dave. Compiler verification: a bibliography. ACM SIGSOFT Software Engineering Notes, 28(6), 2003. J. Guttman, J. Ramsdell, and M. Wand. VLISP: A verified implementation of scheme. Lisp and Symbolic Computation, 8(1/2), 1995.
TRUE , λx. λy. x y
T. Hardin, L. Maranget, and B. Pagano. Functional runtime systems within the lambda-sigma calculus. Journal of Functional Programming, 8, 1998.
FALSE , λx. λy. y x if t then s1 else s2 , t (λx. s1 ) (λx. s2 ) x 6∈ FVar(s1 ) ∪ FVar(s2 )
A. Kennedy. Securing the .NET programming model. Theoretical Computer Science, 364(3), 2006.
rec t , λx. (λy. t (λz. y y z)) (λy. t (λz. y y z)) x x, y 6∈ FVar(t), z 6= y
J. L. Krivine. Classical logic, storage operators and second-order lambda calculus. Annals of Pure and Applied Logic, 1994. P. Landin. The mechanical evaluation of expressions. Journal, 6(4), 1964.
The Computer
The small-step call by value operational semantics of VULE is given as follows:
X. Leroy. Formal certification of a compiler back-end, or: programming a compiler with a proof assistant. In 33rd ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages (POPL), 2006.
t0 s0
t s
X. Leroy and H. Grall. Coinductive big-step operational semantics. Information and Computation, 207(2), 2009.
u ≈α v u 6≈α v
J. Longley. When is a functional program not a functional program? In 4th ACM SIGPLAN International Conference on Functional Programming (ICFP), 1999. J. McCarthy and J. Painter. Correctness of a Compiler for Arithmetic Expressions. Proceedings Symposium in Applied Mathematics, 19:33– 41, 1967.
where x ∈ V, u, v ∈ Val, t, t0 , s, s0 ∈ Term and where u ≈α v means that u is alpha-equivalent to v. For convenience, we define + + the multi-step relation by setting t t0 iff the term t reaches 0 t in one or more steps. Although we have used lambda-encodings of booleans, conditionals and recursion, so as to be both concrete and minimal, the argument we shall make does not depend on these details. In fact we will only rely on some elementary properties of our encodings. The following hold for any v ∈ Val and t, s1 , s2 ∈ Term:
A. M. Pitts and I. D. B. Stark. Operational reasoning for functions with local state. In Higher Order Operational Techniques in Semantics. CUP, 1998. G. D. Plotkin. LCF considered as a programming language. Theoretical Computer Science, 5, 1977. J. Vouillon and P.-A. Melli`es. Semantic types: A fresh look at the ideal model for types. In 31st ACM Symposium on Principles of Programming Languages (POPL), 2004.
(Prop 1) TRUE, FALSE and rec t are values;
G. Winskel. The Formal Semantics of Programming Languages. MIT Press, 1993.
(Prop 2) if TRUE then s1 else s2 (Prop 3) if FALSE then s1 else s2
A.
t0 s v s0 t [x 7→ v] TRUE FALSE ERROR
=⇒ t s =⇒ v s (λx. t) v =⇒ u ≡α v =⇒ u ≡α v ERROR v
The Problem With Realizing Recursion
(Prop 4) (rec t) v
This appendix gives a concrete example of how defining semantics for functional types as sets of untyped programs can run into problems in the case that the source language includes recursion and the untyped target includes non-functional operations. By nonfunctional operations, we particularly mean operations that examine the actual syntax or code of functional terms. The example we take here is a syntactic equality test: an operation that takes two ar-
+
+ +
s1 ; s2 ;
t (rec t) v.
The problem. We now define the problem. Let Type be the set of simple types defined by the rule T ∈ Type := Bool | T → T . As usual, the type constructor → is right-associative. Now we consider what happens if we try to give a semantics to these types as
106
B.
sets of closed VULE terms that admit recursively-defined functions. Whatever the details, we would expect the following, very minimal, conditions to be satisfied by the interpretation of types J−K : Type → P(Term):
On the non-closure of Dt
The reason {d | v Dt d} is not always closed under taking limits of chains is essentially that (·)⊥⊥ does not preserve meets. In particular \ >> [ > > \ >> Pi = (Pi ) ⊃ Pi
(Asm 1) TRUE, FALSE ∈ JBoolK.
(Asm 2) Given a value u ∈ JA1 → . . . → An → BK and values v1 ∈ JA1 K . . . vn ∈ JAn K, the application of u to the vi s does + not go wrong: u v1 . . . vn / ERROR.
i
i
i
v for all values v ∈ JAK, then
with the last inclusion following from contravariance and the strict inclusion [ > \ > Pi ⊂ Pi .
We now show that, rather distressingly, for any semantics J−K satisfying the above conditions, the fixed point combinator Y defined by
Now let hdfi i be a chain of elements of Jt1 → t2 K with ti dfi = df , such that for all i and for all dv ∈ Jt1 K, dfi dv A ⊥. Then we claim that in general, there is a strict inclusion
+
(Asm 3) For a value u, if u v u ∈ JA → AK.
Y
i
{f | ∀i, f Dt1 →t2 dfi } ⊃ {f | f Dt1 →t2 df }
λf. rec f
,
is not in the set
Expanding the definitions, f being in the right-hand set above means exactly that for all v, dv such that v Dt1 dv,
J((Bool → Bool) → Bool → Bool) → Bool → BoolK.
B
(([App], [v, f ]), λ∗. df dv) ∈ (I → Dt⊥2 )
Let the value F be defined by F
,
which expands to
λg. λf. if f ≡α (rec g) then ERROR else f
∀d, d = [df dv] ⇒ ([App], [v, f ]) ∈ ⇑(↓({w | w Dt2 d}))
and observe the following facts about the behaviour of F :
or, equivalently (because of the non-⊥ assumption),
(Obs 1): For any value v ∈ Val, (rec F ) v
+
([App], [v, f ]) ∈ ⇑(↓({w | ∀d, d = [df dv] ⇒ w Dt2 d}))
F (rec F ) v ERROR if v ≈α rec rec F v otherwise
+
(Obs 2): (rec rec F ) TRUE ERROR TRUE ERROR (Obs 3): Y (rec F ) TRUE
+
i
(rec F ) (rec rec F ) TRUE (rec rec F ) TRUE
+
which is to say, ([App], [v, f ]) is in ⇑(⇓({w b | ∀d, d = [df dv] ⇒ w Dt2 d})).
Using similar reasoning, one can deduce that f being in the lefthand side of our inclusion is equivalent to saying that for all v, dv such that v Dt1 dv, the computation ([App], [v, f ]) is in \ ⇑(⇓({w b | ∀d, d = [dfi dv] ⇒ w Dt2 d})). (2)
+
ERROR
From these observations, we can conclude that
i
But there is in general a strict inclusion between the set in (1) and that in (2). By down-closure,
(Con 1): rec rec F 6∈ JBool → BoolK because TRUE ∈ JBoolK + (Asm 1), (rec rec F ) TRUE ERROR (Obs 2), and welltyped applications don’t go wrong (Asm 2).
⊆
(Con 2): rec F ∈ J(Bool → Bool) → Bool → BoolK because rec F behaves as the identity on all values except rec rec F (Obs 1), and since rec rec F is not in JBool → BoolK (Con 1), rec F must behave as the identity on all values that are in JBool → BoolK, we get the conclusion by (Asm 3).
⊆ ⊂
⇑(⇓({w b | ∀d, d = [df dv] ⇒ w Dt2 d})) T b | ∀d, d = [dfi dv] ⇒ w Dt2 d})) ⇑(⇓( i {w T ⇑(⇓({ w b | ∀d, d = [dfi dv] ⇒ w Dt2 d})) i
by the general property of biorthogonals above. To address this issue, we can either add in ‘just enough’ limits or close up under all limits. The first version of our definitions looked like G def v t dv ⇐⇒ ∃hdi i, dv v di ∧ ∀i, v Dt di
Y 6∈ J((Bool → Bool) → Bool → Bool) → Bool → BoolK
because
+
{w b | ∀d, d = [df dv] ⇒ w Dt2 d} T b | ∀d, d = [dfi dv] ⇒ w Dt2 d} i {w
so
(Con 3): Now we see that
Y (rec F ) TRUE ERROR rec F ∈ J(Bool → Bool) → Bool → BoolK TRUE ∈ JBoolK
(1)
(Obs 3) (Con 2) (Asm 1)
i
which adds limits of arbitrary chains to (and down-closes) the righthand side of the relation. The resulting relation may still not be closed under limits of chains, but actually works perfectly well for our purposes, as it does contain limits of all chains arising in the semantics of functions in our language. (And we even proved all the theorems in Coq using this definition. . . ) Nevertheless, it seems mathematically more natural to work with Scott-closed sets, so we have now adopted the definition using Clos(·) shown in the main text.
and well-typed applications don’t go wrong (Asm 2). This is not at all what we wanted! One would expect (Con 2) to be false, since F is clearly highly suspicious, and then the blameless Y could have the expected type. Our analysis of the problem is that (Asm 3) is the only one of our assumptions that could be modified in order to get the expected result. In short, just testing with ‘good’ arguments is actually insufficient grounds for concluding that a function is good: we need some extra tests on ‘partially good’ values, which is just what step-indexing will supply.
107
Scribble: Closing the Book on Ad Hoc Documentation Tools Matthew Flatt
Eli Barzilay
Robert Bruce Findler
University of Utah and PLT
[email protected]
Northeastern University and PLT
[email protected]
Northwestern University and PLT
[email protected]
Abstract Scribble is a system for writing library documentation, user guides, and tutorials. It builds on PLT Scheme’s technology for language extension, and at its heart is a new approach to connecting prose references with library bindings. Besides the base system, we have built Scribble libraries for JavaDoc-style API documentation, literate programming, and conference papers. We have used Scribble to produce thousands of pages of documentation for PLT Scheme; the new documentation is more complete, more accessible, and better organized, thanks in large part to Scribble’s flexibility and the ease with which we cross-reference information across levels. This paper reports on the use of Scribble and on its design as both an extension and an extensible part of PLT Scheme. Categories and Subject Descriptors I.7.2 [Document and Text Processing]: Document Preparation—Languages and systems General Terms
1.
Design, Documentation, Languages
Documentation as Code
Most existing documentation tools fall into one of three categories: LATEX-like tools that know nothing about source code; JavaDoclike tools that extract documentation from annotations in source code; and WEB-like literate-programming tools where source code is organized around a prose presentation. Scribble is a new documentation infrastructure for PLT Scheme that can support and integrate all three kinds of tools. Like the LATEX category, Scribble is suitable for producing stand-alone documents. Like the other two categories, Scribble creates a connection between documentation and the program that it describes—but without restricting the form of the documentation like JavaDoc-style tools, and with a well-defined connection to the language’s scoping that is lacking in WEB-like tools. Specifically, Scribble leverages lexical scoping as supplied by the underlying programming language, instead of ad hoc textual manipulation, to connect documentation and code. This connection supports abstractions across the prose and code layers, and it enables a precise and consistent association (e.g., via hyperlinks) of references in code fragments to specifications elsewhere in the documentation. For example, @scheme[circle] in a document source generates the output text circle. If the source form appears within a lexical context that imports the slideshow library, then the rendered circle is hyperlinked to the documentation for the
Figure 1: DrScheme with binding arrows and documentation links on Scribble code slideshow library—and not to the documentation of, say, the htdp/image library, which exports a circle binding for a different GUI library. Moreover, the hyperlink is correct even if @scheme[circle] resides in a function that is used to generate documentation, and even if the lexical context of the call does not otherwise mention slideshow. Such lexically scoped fragments of documentation are built on the same technology as Scheme’s lexically scoped macros, and they provide the same benefits for documentation abstraction and composition as for ordinary programs. To support documentation in the style of JavaDoc, a Scribble program can “include” a source library and extract its documentation. Bindings in the source are reflected naturally as crossreferences in the documentation. Similarly, a source program can use module-level imports to introduce and compose literateprogramming forms; in other words, the module system acts as the language that Ramsey (1994) has suggested to organize the composition of noweb extensions. Scribble’s capacity to span documentation-tool categories is a consequence of PLT Scheme’s extensibility. Extensibility is an obstacle for JavaDoc-style tools, which parse a program’s source text and would have to be extended in parallel to the language. Scribble, in contrast, plugs into the main language’s extensibility machinery, so it both understands language extensions and is itself extensible. Similarly, Scheme macros accommodate a WEB-like
Permission to make digital or hard copies of all or part of this work for personal or classroom use is granted without fee provided that copies are not made or distributed for profit or commercial advantage and that copies bear this notice and the full citation on the first page. To copy otherwise, to republish, to post on servers or to redistribute to lists, requires prior specific permission and/or a fee. ICFP’09, August 31–September 2, 2009, Edinburgh, Scotland, UK. c 2009 ACM 978-1-60558-332-7/09/08. . . $5.00 Copyright
109
organization of a library’s implementation, and the same macros can simultaneously organize the associated documentation. Indeed, Scribble documents are themselves Scheme programs, which means that PLT Scheme tools can work on Scribble sources. Figure 1 shows this paper’s source opened in DrScheme. After clicking Check Syntax, then a right-click on a use of emph directly accesses the documentation of the emph function, even though the surface syntax of the document source does not look like Scheme. Such documentation links are based on the same lexical information and program-expansion process that the compiler uses, so the links point precisely to the right documentation. We developed Scribble primarily for stand-alone documentation, but we have also developed a library for JavaDoc-style extraction of API documentation, and we have created a WEB-style tool for literate programming. In all forms, Scribble’s connection between documentation and source plays a crucial role in crossreferencing, in writing examples within the documentation, and in searching the documentation from within the programming environment. These capabilities point the way toward even more sophisticated extensions, and they illustrate the advantages of treating documentation as code.
2.
The initial #lang scribble/doc line declares that the module uses Scribble’s documentation syntax, as opposed to using #lang scheme for S-expression syntax. At the same time, the #lang line also imports all of the usual PLT Scheme functions and syntax. The @(require scribble/manual) form imports additional functions and syntactic forms specific to typesetting a user manual. The remainder of the module represents the document content. The semantics of the document body is essentially that of Scheme, where most of the text is represented as Scheme strings. Although we build Scribble on Scheme, a LATEX-style syntax works better than nested S-expressions, because it more closely resembles the resulting textual layout. First, although all of the text belongs in a section, it is implicitly grouped by the section title, instead of explicitly grouped into something like a section function call. Second, the default parsing mode is “text” instead of “expression,” so that commas, periods, quotes, paragraphs, and sections behave in the usual way for prose, while the @ notation provides a uniform way to escape to a Scheme function call with text-mode arguments. Third, various automatic rules convert ASCII to more sophisticated typeset forms, such as the conversion of --to an em-dash and ‘‘...’’ to curly quotes. Although LATEX and Scribble use a similar syntax, the semantics are completely different. For example, itemize is a function that accepts document fragments created by the item function, instead of a text-parsing macro like LATEX’s itemize environment. The square brackets after itemize in the document source reflect that it accepts item values, whereas item and many other functions are followed by curly braces that indicate text arguments. The @notation is simply another way of writing S-expressions, as we describe in detail in Section 4.
Scribbling Prose
The beginning of the PLT Scheme overview documentation demonstrates several common typesetting forms: 1
Welcome to PLT Scheme
Depending on how you look at it, PLT Scheme is • a programming language — a descendant of Scheme,
3.
which is a dialect of Lisp;
Scribbling Code
The PLT Scheme tutorial “Quick: An Introduction to PLT Scheme with Pictures” starts with a few paragraphs of prose and then shows the following example interaction:
• a family of programming languages — variants of Scheme,
and more; or • a set of tools for using a family of programming lan-
guages. > 5 5 > "art gallery" "art gallery"
Where there is no room for confusion, we use simply “Scheme” to refer to any of these facets of PLT Scheme. The Scribble syntax for generating this document fragment is reminiscent of LATEX, using @ (like texinfo) instead of \:
The > represents the Scheme prompt. The first 5 is a constant value in an expression, so it is colored green in the tutorial, while the second 5 is a result, so it is colored blue. In Scheme, the syntax for output does not always match the syntax for expressions, so the different colors are useful hints to readers—but only if they have the consistency of an automatic annotation. The source code for the first example is simply
#lang scribble/doc @(require scribble/manual) @section{Welcome to PLT Scheme} Depending on how you look at it, @bold{PLT Scheme} is
@interaction[5 "art gallery"]
@itemize[ @item{a @emph{programming language} --- a descendant of Scheme, which is a dialect of Lisp;}
where interaction is provided by a Scribble library to both evaluate examples and typeset the expressions and results with syntax coloring. Since example expressions are evaluated when the document is built, examples never contain bugs where evaluation does not match the predicted output. The second example in the “Quick” tutorial shows a more interesting evaluation:
@item{a @emph{family} of programming languages --- variants of Scheme, and more; or} @item{a set of @emph{tools} for using a family of programming languages.} ]
> (circle 10)
Where there is no room for confusion, we use simply ‘‘Scheme’’ to refer to any of these facets of PLT Scheme.
110
Here, again, the expression is colored in the normal way for PLT Scheme code. More importantly, the circle identifier is hyperlinked to the definition of the circle function in the slideshow library, so an interested reader can follow the link to learn more about circle. Meanwhile, the result is shown as a circle image, just as it would be shown when evaluating the expression in DrScheme. The source code for the second example is equally simple:
Alternatively, instead of writing the documentation for circle in a stand-alone document—where there is a possibility that the documented contract does not match the contract in the implementation—the documentation could be written with the implementation of circle. In that case, the documentation would look slightly different, since it would be part of the module’s export declarations: (provide/doc [circle ([diameter real?] . -> . pict?) @{Creates an unfilled ellipse.}])
@mr-interaction[(circle 10)]
The author of the tutorial had to implement the mr-interaction syntactic form, because interaction does not currently support picture results. The syntax coloring, hyperlinking, and evaluation of (circle 10), however, is implemented by expanding to interaction. In particular, circle is correctly hyperlinked because the module containing the above source also includes
With provide/doc, the single contract specification for circle is used in two ways: at run time to check arguments and results for circle, and when building the documentation to show the expected arguments and results of circle. Although defproc and provide/doc are provided with Scribble, they are not built into the core typesetting engine. They are written in separate libraries, and Scribble users could have implemented these forms. We describe this approach to extending Scribble further in Section 8.
@(require (for-label slideshow))
which causes the circle binding to be imported from the slideshow module for the purposes of hyperlinking. Based on this import and a database mapping bindings to definition sites, Scribble can automatically insert the hyperlink. A module that is imported only with for-label is not run when the documentation is built, because the time at which a document is built may not be a suitable time to actually run a module. As an extreme example, an author might want to document a module whose job is to erase all files on the disk. More practically, executing a GUI library might require a graphics terminal, while the documentation for the graphics library can be built using only a text terminal. Pervasive and precise hyperlinking of identifiers greatly improves the quality of documentation, and it relieves a document author from much tedious cross-referencing work, much like automatic hyperlinking in wikis. The author need not specify where circle is documented, but instead merely import for-label a module that supplies circle, and the documentation system is responsible for correlating the use and the definition. Furthermore, since hyperlinks are used in examples everywhere, an author can expect readers to follow them, instead of explicitly writing “for more information on the circle procedure used above, see ...” These benefits are crucial when a system’s documentation runs to thousands of pages. Indeed, PLT Scheme’s documentation has 57,997 links between manuals, which is roughly 15 links per printed page (and which does not count the additional 105,344 intra-manual links). Clicking the circle hyperlink leads to its documentation in a standard format:
4. @s and []s and {}s, Oh My! Users of a text-markup language experience first and foremost the language’s concrete syntax. The same is true of any language, but in the case of text, authors with different backgrounds have arrived at a remarkably consistent view of the appropriate syntax: it should use blank lines to indicate paragraph breaks, double-quote characters should not be special, and so on. At the same time, a programmable mark-up language needs a natural escape to the programming layer and back. From the perspective of a programming language, conventional notations for string literals are terrible for writing text. The quoting rules tend to be complex, and they usually omit an escape for arbitrarily nested expressions. “Here strings” and string interpolation can alleviate some of the quoting and escape problems, but they are insufficient for writing large amounts of text with frequent nested escapes to the programming language. More importantly, building text in terms of string escapes and operations like string-append distracts from the business of writing prose, which is about text and markup rather than strings and function calls. Indeed, many documentation systems, like JavaDoc, avoid the limitations of string literals in the language by defining a completely new syntax that is embedded within comments. Of course, this approach sacrifices any connection between the text and the programming language. For Scribble, our solution is the @-notation, which is a textfriendly alternative to traditional S-expression syntax. More precisely, the @-notation is another way to write down arbitrary Sexpressions, but it is tuned for writing blocks of free-form text. The @-expression notation is a strict extension of PLT Scheme’s S-expression syntax; the @ character has no special meaning in Scheme strings, in comments, or in the middle of Scheme identifiers. Furthermore, since it builds on the existing S-expression parser, it inherits all of the existing source-location support (e.g., for error messages).
(circle diameter) → pict? diameter : real? Creates an unfilled ellipse. In this definition, real? and pict? are contracts for the function argument and result. Naturally, they are in turn hyperlinked to their definitions, because suitable libraries are imported for-label in the documentation source. The above documentation of circle is implemented using defproc:
4.1
@-expressions as S-expressions
The grammar of an @-expression is roughly as follows (where @, [, ], {, and } are literal, and x? means that x is optional): hat-expri hopi hS-expri htexti
@defproc[(circle [diameter real?]) pict?]{ Creates an unfilled ellipse. }
111
::= ::= ::= ::=
@hopi? [hS-expri*]? {htexti}? hS-expri that does not start with [ or { any PLT Scheme S-expression text with balanced {...} and with @-exprs
An @-expression maps to an S-expression as follows:
Another way to describe the @-expression syntax is simply @hopi[...]{...} where each of the three parts is optional. When hopi is included but both kinds of arguments are missing, then hopi can produce a value to use directly instead of a function to call. The hopi in an @-expression is not constrained to be an identifier; it can be any S-expression that does not start with { or [. For example, an argumentless @(require scribble/manual) is equivalent to the S-expression (require scribble/manual). The spectrum of @-expression forms enables a document author to use whichever variant is most convenient. For a given operation, however, one particular variant is typically used. In general, @hopi{...} or @hopi[...] is used to imply a typesetting operation, whereas @hopi more directly implies an escape to Scheme. Hence, the form @emph{Yes!} is preferred to the equivalent @(emph "Yes!"), while @(require scribble/manual) is preferred to the equivalent @require[scribble/manual]. A combination of S-expression and text-mode arguments is often useful to “customize” an operation that consumes text. The @title[#:style ’toc]{Contracts} example illustrates this combination, where the optional ’toc style customizes the typeset result of the title function. In other cases, an operation that specifically leverages S-expression notation may also have a text component. For example,
• An @hopi{...} sequence combines hopi with text-mode argu-
ments. For example, @emph{Yes!}
is equivalent to the S-expression (emph "Yes!")
Also, since @ keeps its meaning inside text-mode arguments, @section{Country @emph{and} Western}
is equivalent to the S-expression (section "Country " (emph "and") " Western") • An @hopi[...] sequence combines hopi with S-expression ar-
guments. For example, @itemize[(item "a") (item "b")]
@defproc[(circle [diameter real?]) pict?]{ Creates an unfilled ellipse. }
is equivalent to the S-expression (itemize (item "a") (item "b"))
is equivalent to
• An @hopi[...]{...} sequence combines S-expression argu-
(defproc (circle [diameter real?]) pict? "Creates an unfilled ellipse.")
ments and text-mode arguments. For example, @title[#:style ’toc]{Contracts}
but as the description of the procedure becomes more involved, using text mode for the description becomes much more convenient. An @ works both as an escape from text mode and as a form constructor in S-expression contexts. As a result, @-forms keep their meaning whether they are used in a Scheme expression or in a Scribble text part. This equivalence significantly reduces the need for explicit quoting and unquoting operations, and it helps avoid bugs due to incorrect quoting levels. For example, instead of @itemize[(item "a") (item "b")], an itemization is normally written @itemize[@item{a} @item{b}], since items for an itemization are better written in text mode than as conventional strings; in this case, @item{a} can be used directly without first switching back to text mode. Overall, @-expressions are crucial to Scribble’s flexibility in the same way that S-expressions are crucial to Scheme’s flexibility— and, in the same way, the benefit is difficult to quantify. Furthermore, just as S-expressions can be used for more than writing Scheme programs, the @ notation can be used for purposes other than documentation, and the @-notation parser is available for use in PLT Scheme separate from the rest of the Scribble infrastructure. We use it as an alternative to HTML for building the plt-scheme.org web pages, more generally in a template system supported by the PLT Scheme web server, and also as a text preprocessor language similar in spirit to m4 for generating plaintext files.
is equivalent to the S-expression (title #:style ’toc "Contracts")
where #:style uses PLT Scheme notation for a keyword. • An @hopi sequence without an immediately following { or [ is
equivalent to just hopi in Scheme mode. For example, @username
is equivalent to the S-expression username
so that @emph{committed by @username}
is equivalent to (emph "committed by " username) • An hopi can be omitted in any of the above forms. For example, @{Country @emph{and} Western}
is equivalent to the S-expression
4.2
Documentation-Specific Decoding
The @ notation supports local text transformations and mark-up, but it does not directly address some other problems specific to organizing a document’s source:
("Country " (emph "and") " Western")
which is useful in some quoted or macro contexts.
112
• Section content should be grouped implicitly via section,
a doc binding whose value is an instance of the Scribble part structure type. For example,
subsection, etc. declarations, instead of explicitly nesting section constructions.
#lang scheme (require scribble/decode) (define doc (decode ’("Hello, world!"))) (provide doc)
• Paragraph breaks should be determined by empty lines in the
source text, instead of explicitly constructing paragraph values. • A handful of ASCII character sequences should be converted
automatically to more sophisticated typesetting elements, such as converting ‘‘ and ’’ to curly quotes or --- to an em-dash.
implements in Scheme notation a Scribble document that contains only the text “Hello, world!” Larger documents are typically split across modules/files along section boundaries. Subsections are incorporated into a larger section using the include-section form, which expands to a require to import the sub-section module and an expression that produces the doc part exported by the module. Since document inclusion corresponds to module importing, all of the usual PLT Scheme tools for building and executing modules apply to Scribble documents. When a large document source is split into multiple modules, most of the modules need the same basic typesetting functions as well as the same “standard” bindings for examples. In Scribble, both sets of bindings can be packaged together; since for-label declarations build on the module system’s import mechanisms, they work with the module system’s re-exporting mechanisms. For example, the documentation for a library that builds on the scheme/base library might use this "common.ss" library:
These transformations are specific to typesetting, and they are not appropriate for other contexts where the @ notation is useful. Therefore, the @ parser in Scribble faithfully preserves the original text in Scheme strings, and a separate decode layer in Scribble provides additional transformations. Functions like bold and emph apply decode-content to their arguments to perform ASCII transformations, and item calls decode-flow to transform ASCII sequences and form paragraphs between empty lines. In contrast, tt and verbatim do not call the decode layer, and they instead typeset text exactly as it is given. For example, the source document #lang scribble/doc @(require scribble/manual) @title{Tubers}
#lang scheme/base (require scribble/manual (for-label lang/htdp-beginner)) (provide (all-from-out scribble/manual) (for-label (all-from-out lang/htdp-beginner)))
@section{Problem} You say ‘‘potato.’’ I say ‘‘potato.’’
Then, each part of the document can be implemented as
@section{Solution}
#lang scribble/doc @(require "common.ss") ....
Call the whole thing off.
invokes the decode layer, producing a module that is roughly equivalent to the following (where a part is a generic section):
instead of separately requiring scribble/manual and (forlabel lang/htdp-beginner) in every file.
#lang scheme/base (require scribble/struct) (provide doc)
6.
(define doc (make-part (list "Tubers") (list (make-part (list "Problem") (list (make-paragraph (list "You say \u201Cpotato.\u201D")) (make-paragraph (list "I say \u201Cpotato.\u201D")))) (make-part (list "Solution") (list (make-paragraph (list "Call the whole thing off.")))))))
5.
Modules and Bindings
As an embedded domain-specific language, Scribble follows a long tradition of using Lisp- and Scheme-style macros to implement little languages. In particular, Scribble relies heavily on the Scheme notion of syntax objects (Sperber 2007), which are fragments of code that have lexical-binding information attached. Besides using syntax objects in the usual way to implement macros, Scribble uses syntax objects to carry lexical information all the way through document rendering. For example, @scheme[lambda] expands to roughly (typeset-id #’lambda), where #’lambda is similar to ’lambda but produces a syntax object (with its lexical information intact) instead of a symbol. At the same time, many details of Scribble’s implementation rely on PLT Scheme extensions to Scheme macros. Continuing the above example, the typeset-id function applies PLT Scheme’s identifier-label-binding function to the given syntax object to determine the source module of its binding. The typeset-id function can then construct a cross-reference key based on the identifier and the source module; the documentation for the binding pairs the same identifier and source module to define the target of the cross-reference. A deeper dependence of Scribble on PLT Scheme relates to #lang parsing. The #lang notation organizes reader extensions of Scheme (i.e., changes to the way that raw text is converted to S-
Document Modules
Like all PLT Scheme programs, Scribble documents are organized into modules, each in its own file. A #lang line starts a module, and most PLT Scheme modules start with #lang scheme or #lang scheme/base. A Scribble document normally starts with #lang scribble/doc to use a prose-oriented notation with @ syntax, but a Scribble document can be written in any notation and using any helper functions and syntax, as long as it exports
113
Aside from (1) the ability to force the expansion of nested forms and (2) the ability of macros to expand into new imports, macro expansion of a module body is essentially the same as for libraries in the current Scheme standard (Sperber 2007). Where the standard allows choice in the separation of phases, we have chosen maximal separation in PLT Scheme, so that compilation and expansion as consistent as possible (Flatt 2002). That is, bindings and module instantiations needed during the compilation of a module are kept separate from the bindings and instantiations needed when executing a module for rendering. Furthermore, to support the connection between documentation and library bindings, PLT Scheme introduces a new phase that is orthogonal to compile time or run time: the label phase level. As noted in Section 3, a for-label import introduces bindings for documentation without triggering the execution of the imported module. In PLT Scheme, the same identifier can have different bindings in different phases. For example, when documenting the Intermediate Scheme pedagogical language, a document author would like uses of lambda to link to the lambda specification for Intermediate Scheme, while procedures used to implement the document itself will more likely use the full PLT Scheme language, which is a different lambda. The two different uses of lambda are kept straight naturally and automatically by separate bindings in separate phases.
expressions) to allow new forms of surface syntax. The identifier after #lang in the original source act as the “language” of a module. To parse a #lang line, the identifier after #lang is used as the name of a library collection that contains a "lang/reader.ss" module. The collection’s "lang/reader.ss" module must export a read-syntax function, which takes an input stream and produces a syntax object. The "lang/reader.ss" module for scribble/doc parses the given input stream in @-notation text mode, and then wraps the result in a module form. For example, #lang scribble/doc @(require scribble/manual) It was a @bold{dark} and @italic{stormy} night.
in a file named "hello.scrbl" reads as (module hello scribble/doclang doc () "\n" (require scribble/manual) "\n" "It was a " (bold "dark") " and " (italic "stormy") "night." "\n")
where doc is inserted by the scribble/doc reader as the identifier to export from the module, and the () is a convenience explained below. The module form is PLT Scheme’s core module form, and it generalizes the standard library form (Sperber 2007) to give macros more control in transforming the body of a module. Within a module, the first identifier is the relative name of the module, and the second identifier indicates a module to supply initial bindings for the module body. In particular, the initial import of a module is responsible for supplying a #%module-begin macro that is implicitly applied to the entire content of the module. In the case of scribble/doclang, the #%module-begin macro lifts out all import and definitions forms in the body, passes all remaining content to the decode function, and binds the result to an exported doc identifier. Thus, macro expansion converts the hello module to the following:
7.
Core Scribble Datatypes
The doc binding that a Scribble module exports is a description of a document. Various tools, such as the scribble command-line program, can take this description of a document and render it to a specific format, such as LATEX or HTML. In particular, Scribble defers detailed typesetting work to LATEX or to HTML browsers, and Scribble’s plug-in architecture accommodates new rendering back-ends. Scribble’s documentation abstraction reflects a least-common denominator among such document formats. For example, Scribble has a baked-in notion of itemization, since LATEX, HTML, and other document formats provide specific support to typeset itemizations. For many other layout tasks, such as formatting Scheme code, Scribble documents fall back to a generic “table” abstraction. Similarly, Scribble itself resolves most forms of cross-references and document dependencies, since different formats provide different levels of automatic support; tables of contents and indexes are mostly built within Scribble, instead of the back-end. A Scribble document is a program that generates an instance of a part structure type. A part can represent a section or a book, and it can have sub-parts that represent sub-sections or chapters. This paper, for example, is generated by a Scribble document whose resulting part represents the whole paper, and it contains sub-parts for individual sections. The part produced by a Scheme document for a reference manual is rendered as a book, where the immediate sub-parts are chapters. Figure 2 summarizes the structure of a document under part in a UML-like diagram. When a field contains a list, the diagram shows a double arrow, and when a field contains a lists of lists, the diagram shows a triple arrow. The dashed arrows call attention to delayed fields, which are explained below. Each part has a flow that is typeset before its sub-parts (if any), and that represents the main content of a section. A flow is a list of blocks, where each block is one of the following:
(module hello scheme/base (require scribble/doclang scribble/manual) (provide doc) (define doc (decode "\n" "\n" "It was a " (bold "dark") " and " (italic "stormy") "night." "\n")))
A subtlety in the process of lifting out import and definition forms is that they might not appear directly, but instead appear in the process of macro expansion. For example, includesection expands to a require of the included document plus a reference to the document. The #%module-begin macro of scribble/doclang therefore relies on a PLT Scheme facility for forcing the expansion of sub-forms. Specifically, #%modulebegin uses local-expand to expand each sub-form just far enough to determine whether it is an import form, definition form, or expression. If the sub-form is an import or definition, then #%module-begin suspends further work and lifts out the import or definition immediately; the import or definition can then supply bindings for further expansion of the module body. The need to suspend and continue lifting explains the () inserted in the body of a module by the scribble/doc reader; #%module-begin uses that position to track the sub-forms that have been expanded already to expressions.
• a paragraph, which contains a list of elements that are type-
set inline with automatic line breaks; • a table, which contains a list of rows, where each row is a list
of flows, one per cell in the table;
114
the elements to elsewhere in the document (which is rendered in HTML as a hyperlink from the elements);
part title flow subparts
• a delayed-element eventually expands to a list of ele-
ments. Like a delayed-block, it typically generates the elements using information gathered from elsewhere in the document. A delayed-element often generates a linkelement after a suitable target for cross-referencing is located.
flow blocks
block
paragraph style elements
• A collect-element is the complement of delayed-
element: it includes an immediate list of elements, but also a procedure to record information that might be used elsewhere in the document. A collect-element often includes a target-element, in which case its procedure might register the target’s cross-reference tag for discovery by delayedelement instances.
delayed-block block itemization style items
• A few other element types support more specialized tasks, such
as communicating between phases and specifying tooltips. blockquote style flow
table style cells
A document as represented by a part instance is an immutable value. This value is transformed in several passes to eliminate delayed-block instances, delayed-element instances, and collect-element instances. The result is a simplified part instance and associated cross-reference information. Once the cross-reference information has been computed, it is saved for use when building other documents that have cross-references to this one. Finally, the part instance is consumed by a rendering back-end to produce the final document. In the current implementation of Scribble, all documents are transformed in only two passes: a collect pass that collects information about the document (e.g., through collect-elements), and a resolve pass that turns delayed blocks and elements into normal elements. We could easily generalize to multiple passes, but so far, two passes have been sufficient within a single document. When multiple documents that refer to each other are built separately, these passes are iterated as explained in Section 9. In some cases, the output of Scribble needs customization that is specific to a back-end. Users of Scribble provide the customization information by supplying a mapping from the contents of the style field in the various structures for the style’s back-end rendering. For HTML output, a CSS fragment can extend or override the default Scribble style sheet. For LATEX output, a ".tex" file can extend or redefine the default Scribble LATEX commands.
element style
collect-element elements
string
target-element tag elements
delayed-element elements
link-element tag elements
Figure 2: Scribble’s core document representation • an itemization, which contains a list of flows, one per item; • a blockquote, which contains a single flow that is typically
typeset with more indentation than its surrounding flow; or • a delayed-block, which eventually expands to another
block, using information gathered elsewhere in the document. Accordingly, the block field of a delayed-block is not just a block, but a function that computes a block when given that other information. For example, a delayed-block is used to implement a table of contents.
8.
Scribble’s Extensibility
Scribble’s foundation on PLT Scheme empowers programmers to implement a number of features as libraries that ordinarily must be built into a documentation tool. More importantly, users can experiment with new and more interesting ways to write documentation without having to modify Scribble’s implementation. In this section, we describe several libraries that we have already built atop Scribble: for stand-alone API documentation, for automatically running examples when building documentation, for combining code with documentation in the style of JavaDoc, and for literate programing.
A Scribble document can construct other kinds of blocks that are implemented in terms of the above built-in kinds. For example, a defproc block that describes a procedure is implemented in terms of a table. An element within a paragraph can be one of the following: • a plain string;
8.1
• an instance of the element structure type, which wraps a list
API Specification
Targets for code hyperlinks are defined by defproc (for functions), defform (for syntactic forms), defstruct (for structure types), defclass (for classes in the object system), and other such forms—one for each form of binding. When a library defines a new form of binding, an associated documentation library can define a new form for documenting the bindings. As we demonstrated in Section 3, the defproc form documents a function given its name, information about its arguments,
of elements with a typesetting style, such as ’bold, whose detailed interpretation depends on the back-end format; • a target-element, which associates a cross-reference tag
with a list of elements, and where the typeset elements are the target for cross-references using the tag; • a link-element, which associates a cross-reference tag to a
list of elements, where the tag designates a cross-reference from
115
8.2
and a contract expression for its result. Information for each argument includes a contract expression, the keyword (if any) for the argument, and the default value (if any). For example, a louder function that consumes and produces a string might be documented as follows:
Examples and Tests
In the documentation for a function or syntactic form, concrete examples help a reader understand how a function works, but only if the examples are reliable. Ensuring that examples are correct is a significant burden in a conventional approach to documentation, because the example expressions must be carefully checked against the implementation (often by manual cut and paste), and a small edit can easily introduce a bug. The examples form of the scribble/eval library typesets an example along with its result using the style of a read-evalprint loop. For example,
@defproc[(louder [str string?]) string?]{ Adds ‘‘!’’ to the end of @scheme[str]. }
The description of the function refers back to the formal argument str using scheme. In the typeset result, the reference to str is typeset in a slanted font both in the function prototype and description.
@examples[(/ 1 2) (/ 1 2.0) (/ 1 +inf.0)]
produces the output Examples: > (/ 1 2) 1/2 > (/ 1 2.0) 0.5 > (/ 1 +inf.0) 0.0
(louder str) → string? str : string? Adds “!” to the end of str.
As usual, lexical scope provides the connection between the formal-argument str and the reference. The defproc form expands to a combination of Scribble functions to construct a table representing the documentation and Scheme local-macro bindings to control the expansion and typesetting of the procedure description. For the above defproc, the for-label binding of louder partly determines the library binding that is documented by this defproc form. A single binding, however, can be re-exported by many modules. On the reference side, the scheme and schemeblock forms follow re-export chains to discover the first exporting module for which a binding is documented; on the definition side, defproc needs a declaration of the module that is being documented. The module declaration is no extra burden on the document author, because the reader of the document needs some indication of which module is being documented. The defmodule form both generates the user-readable explanation of the module being documented and declares that all definitions within the enclosing section (and sub-sections, unless overridden) correspond to exports from the declared module. Thus, if louder is exported by the comics/string library, it is documented as follows:
Since building the documentation runs the examples every time, the typeset results are reliable. When an author makes a mistake, or when an implementation changes so that the documentation is out of sync, the example remains correct—though it may not reflect what the author intended. For example, if we misspell +inf.0 in the example, then the output is still accurate, though unhelpful in describing the behavior of division: Example: > (/ 1 +infinity.0) reference to undefined identifier: +infinity.0 To guard against such mistakes, an example expression can be wrapped with eval:check to combine it with an expected result: @examples[(eval:check (/ 1 +infinity.0) 0.0)]
Instead of typesetting an error message, this checked example will raise an exception when the document is built, because the expression does not produce the expected result 0.0. In this way, documentation source can serve partly as a test suite. Evaluation of example code mingles two phases that we have otherwise worked to keep separate: the time at which a library is executed, and the time at which its documentation is produced. For simple functional expressions, such as (/ 1 2), the separation does not matter, and examples could simply duplicate its argument in both an expression position and a typeset position. More generally, however, examples involve temporary definitions and side-effects. To prevent examples from interfering with each other while building a large document, examples uses a sandboxed environment, for which PLT Scheme provides extensive support (Flatt et al. 1999; Flatt and PLT Scheme 2009, §13).
#lang scribble/doc @(require scribble/manual (for-label scheme/base comics/string)) @title{String Manipulations} @defmodule[comics/string] @defproc[(louder [str string?]) string?]{ Adds ‘‘!’’ to the end of @scheme[str]. }
The defproc form is implemented by a scribble/manual layer of Scribble, which provides many functions and forms for typesetting PLT Scheme documentation. The scribble/manual layer is separate from the core Scribble engine, however, and other libraries can build up defproc-like abstractions on top of the core typesetting and cross-referencing capabilities described in Section 7.
8.3
In-Code Documentation
For some libraries, the programmer may want to write documentation with the source instead of in a separate document. To support such documentation, we have created a Scheme/Scribble extension that is used to document some libraries in the PLT Scheme distribution.
116
Using this extension, the comics/string module could be implemented as follows:
ble/lp and use chunk to introduce a piece of the implementation. A use of chunk consists of a name followed by definitions and/or expressions:
#lang at-exp scheme/base (require scheme/contract scribble/srcdoc) (require/doc scheme/base scribble/manual)
@chunk[ ... definitions ... ... expressions ...]
The definitions and expressions in a chunk can refer to other chunks by their names. Unlike a normal Scribble program, running a scribble/lp program ignores the prose exposition and instead evaluates the program in the chunks. In literate programming terminology, this process is called tangling the program. Thus, to a client module, a literate program behaves just like its illiterate variant. The compiled form of a literate program does not contain any of the documentation, nor does it depend on the runtime support for Scribble, just as if an extra-linguistic tangler had been used. Consequently, the literate implementation suffers no overhead due to the prose. To recover the prose, the
(define (louder s) (string-append s "!")) (provide/doc [louder ([str string?] . -> . string?) @{Adds ‘‘!’’ to the end of @scheme[str].}])
The #lang at-exp scheme/base line declares that the module uses scheme/base language extended with @-notation. The imported scribble/srcdoc library binds require/doc and provide/doc. The require/doc form imports bindings into a “documentation” phase, such as the scheme form that is used in the description of louder. The provide/doc form exports louder, annotates it with a contract for run-time checking, and records the contract and description for inclusion in documentation. The description is an expression in the documentation phase; it is dropped by normal compilation of the module, but combined with the require/doc imports and inferred (require (for-label ...)) imports to generate the module’s documentation. The documentation part of this module is extracted using include-extracted, which is provided by the scribble/extract module in cooperation with scribble/srcdoc. The extracted documentation might provide the entire text of the document directly, or it might be incorporated into a larger document:
@lp-include[filename]
form extracts a literate view of the program from filename. In literate programming terminology, this process is called weaving the program. The right-hand side of Figure 3 shows the woven version of the code in the screenshot. Both weaving and tangling with scribble/lp work at the level of syntactic extensions, and not in terms of manipulating source text. As a result, the language for writing prose is extensible, because Scribble libraries such as scribble/manual can be imported into the document. The language for implementing the program is also obviously extensible, because a chunk can include imports from other PLT Scheme libraries. Finally, even the bridge between the prose and the implementation is extensible, because the document author can create new syntactic forms that expand to a mixture of prose, implementation, and uses of chunk. Tangling via syntactic extension also enables many tools for Scheme programs to automatically apply to literate Scheme programs. The arrows in Figure 3’s screenshot demonstrate how DrScheme can draw arrows from chunk bindings to chunk references, and from the binding occurrence of an identifier to its bound occurrences, even across chunks. These latter arrows are particularly helpful with literate programs, where lexical scope is sometimes obscured by the way that textually disparate fragments of a program are eventually tangled into the same scope. DrScheme’s interactive REPL, test-case coverage support, module browser, executable generation, and other tools also work on literate programs. To gain some experience with non-trivial literate programming in Scribble, we have written a 34-page literate program that describes our implementation of the Chat Noir game, which is distributed with PLT Scheme. The source is included in the distribution as "chat-noir-literate.ss", and the rendered output is in the help system and online at http://docs. plt-scheme.org/games/chat-noir.html.
#lang scribble/doc @(require scribble/manual scribble/extract (for-label comics/string)) @title{Strings} @defmodule[comics/string] The @schememodname[comics/string] library provides functions for creating punchlines. @include-extracted[comics/string]
An advantage of using scribble/srcdoc and scribble/extract is that the description of the function is with the implementation, and the function contract need not be duplicated in the source and documentation. Similarly, the fact that string? in the contract gets its binding from scheme/base is specified once in the code and inferred for the documentation. At the same time, a phase separation prevents document-generating expressions from polluting the library’s run-time execution, and vice versa. 8.4
9.
Literate Programming
Building and Installing Documentation
PLT Scheme documentation resides with the source code. The setup process that builds bytecode from Scheme source also renders HTML documentation from Scribble source. The HTML output is accompanied by cross-reference information that is used both for building more documentation when new libraries are installed and for online help in the programming environment. Although many existing PLT Scheme tools help in building documents, the process of generating HTML is significantly different from compilation tasks. The main difference is that cyclic depen-
The techniques used for in-source documentation extend to the creation of WEB-like literate programming tools. Figure 3 shows an example use of our literate-programming library; the left-hand side shows a screenshot of DrScheme editing the source code for a short, literate discussion of the Collatz conjecture, while the righthand side shows the rendered output. Literate programs written with our library look like ordinary Scribble documents, except that they start with #lang scrib-
117
Consider a function that, starting from (collatz n), recurs with ::= (collatz (/ n 2)) if n is even and recurs with ::= (collatz (+ (* 3 n) 1)) if n is odd. We can package that up into the collatz function: ::= (define (collatz n) (unless (= n 1) (if (even? n) ))) The Collatz conjecture is true if this function terminates for every input. Thanks to the flexibility of literate programming, we can package up the code to compute orbits of Collatz numbers too: ::= (define (collatz n) (cond [(= n 1) ’(1)] [(even? n) (cons n )] [(odd? n) (cons n )])) Finally, we put the whole thing together, after establishing different scopes for the two functions. ::= (require scheme/local) (local [] (collatz 18)) (local [] (collatz 18))
Figure 3: Literate programming example
118
The Scribble-based documentation system is accessible to all PLT Scheme users, who write their own documentation using Scribble and often supply patches to the main document sources. Scribble produces output that is more consistent and easier to navigate than the old documentation, and the resulting documentation works better with online help. More importantly, the smooth path from API documentation to stand-alone documents has let us produce much more tutorial and overview documentation, helping users find their way through the volumes of available information.
dencies are common in documentation, whereas library dependencies are strictly layered. For example, the core language reference contains many pointers into the overview and a few pointers to the GUI library and other extensions; all documents, meanwhile, refer back to the core reference. Resolving mutual dependencies directly would require loading all documents into memory at once, which is impractical for the scale of the PLT Scheme documentation. The setup processes therefore builds documents one at a time, reading and writing serialized cross-reference information until it arrives at a fixed point for all documents. A fixed point usually requires two iterations, so that all documents see the information collected from all other documents. A handful of documents require a third pass, because they contain section titles from other documents, where each section title is based on still other documents (e.g., by using an identifier whose typesetting depends on whether it is documented as a procedure or syntactic form). Another challenge in building a unified set of documentation is that individual users might augment the main installation with userspecific libraries. The main installation includes a table of contents that is the default starting point for reading documentation, and this table is updated when a new package is placed into the main installation. When a user-specific library is installed, in contrast, its document is built so that hyperlink references go into the main installation’s documentation, and a user-specific table of contents is created. When a user opens the documentation via DrScheme’s Help menu, a user-specific table of contents is opened (if it exists). Instead of explicitly installing a library, a user can implicitly install a package from the PLaneT repository (Matthews 2006) by using a library reference of the form (planet ....). When a library is installed in this way, its documentation is installed as the library is compiled. PLaneT supports library versioning, and multiple versions of a package can be installed at a time. In that case, multiple versions of the documentation are installed; document references work with versions just as reliably as library references, since they use the same underlying module-import mechanisms to precisely identify the origin of a binding.
10.
11.
Related Work
As noted in the introduction, most existing documentation tools fall into one of three categories: LATEX-like tools, JavaDoc-like tools, and WEB-like tools. The LATEX category includes general word-processing tools like Microsoft Word, but LATEX offers the crucial advantage of programmability, where macros enable automatic formatting of API details. Systems like Skribe (Gallesio and Serrano 2005) improve LATEX by offering a sane programming language. Even in a programmable documentation language, however, a lack of connection to source code means that information is duplicated in documentation and source, and binding and evaluation rules inherent to the source language are not automatically reflected in documentation and in examples related to those bindings. The JavaDoc category includes perldoc for Perl, RDoc for Ruby, Haddock (Marlow 2002) for Haskell, OCamlDoc (Leroy 2007), Doxygen (van Heesch 2007) for various languages (including Java, C++, C#, and Fortran), and many others. Such tools improve on the LATEX category, in that they provide a closer connection to the programs that they document. In particular, they are specifically designed for library API documentation, where they shine in automatic extraction of API details from the source code. These tools are not suitable for other kinds of stand-alone documents, such as overview documents, tutorials, and papers (like this one), where prose and document structuring are more central than API details. Literate programming tools such as WEB (Knuth 1984) and noweb (Ramsey 1994) are designed for documenting the implementation of a library as much as the API that a library exports. In a sense, these tools are an extreme version of the JavaDoc category, where the information communicated to a reader is drawn from both the prose and the executable source. In doing so, unfortunately, the tools typically revert to a textual slice-and-dice of the program and prose sources, instead of a programmable layer that spans the two halves. Simonis and Weiss (2003) provide a more complete overview of existing systems and add ProgDoc, which is similar to noweb in the way that it uses a pipeline of tools. Scribble builds on many ideas from these predecessors, but fits them into an extensible framework backed by an expressive programming language. Skribe (categorized above in the LATEX group) is by far the system most closely related to Scribble. Like Scribble, Skribe builds on Scheme to construct representations of documents using Scheme functions and macros, and it uses an extension of Scheme syntax to make it more suitable for working with literal text. (Skribe uses square brackets to quote strings, and within square brackets, a comma followed by an open parenthesis escapes back into Scheme.) Skribe’s format-independent document structure and its use of passes to render a document influenced the design of Scribble. Skribe, however, lacks an integration with lexical binding and the module system that is the heart of Scribble. For example, a scheme form that typesets and links and identifier in a lexically sensitive way is not possible to implement in Skribe without building a PLT Scheme-style module and macro layer on top of Skribe. Scribble builds on a long line of work in Lisp-style language extensibility, including traditional Lisp macros, lexically scoped
Experience
Scribble is part of the PLT Scheme distribution as of version 4.0, which was released in June 2008, and all PLT Scheme documentation is created with Scribble. Developing Scribble, porting old PLT Scheme documentation, and writing new documentation took about a year, but the @ notation evolved earlier through years of experimentation. The documentation at http://docs.plt-scheme.org/ is built nightly by Scribble from a snapshot of the PLT Scheme source repository. The same documentation is available in PDF form at http://pre.plt-scheme.org/docs/pdf/. At the time of this writing, the 70 PDF files of current documentation total 3778 pages in a relatively sparse format, which we estimate would fit in around 1000 pages if compressed into a conferencestyle, two-column layout. This total includes documentation only for libraries that are bundled with PLT Scheme; additional libraries for download via PLaneT are also documented using Scribble. PLT Scheme documentation was previously written in LATEX and converted to HTML via tex2page (Sitaram 2007). Although tex2page was a dramatic improvement over our original use of latex2html, the build process relied on layers of fragile LATEX macros, HTML hacks, and pre- and post-processing scripts, which made the documentation all but impossible to build except by its authors. Consequently, most library documentation used a plaintext format that was easier to write but inconsistent in style and difficult to index. The documentation index distinguished identifier names from general terms, but it did not attach a source module to each identifier name, so online help relied on textual search.
119
of no programming system besides PLT Scheme that is distributed with tutorials, programmer guides, and detailed API documentation, all extensively and precisely cross-referenced. We also know of no other system that makes it so easy for third parties to add new documentation of all kinds with the same level of integration, to say nothing of being able to extend the documentation system itself.
macros in Scheme (Dybvig et al. 1993), and readtable-based syntactic extension as in Common Lisp. Phase-sensitive binding through for-label is specific to PLT Scheme, as is the disciplined approach to reader extension embodied by #lang. The SLATEX (Sitaram 2007) system provides automatic formatting of Scheme code within a LATEX document. To identify syntactic forms and constants, SLATEX relies on defkeyword and defconstant declarations. In this mode, the author of a work in progress must constantly add another “standard” binding to SLATEX’s list; SLATEX’s built-in table of syntactic forms is small compared to the number of syntactic forms available in PLT Scheme. More generally, the problem is the usual one for “standards”: there are many to choose from. Scribble solves this problem with for-label imports and by directly using the namespacemanagement functionality of PLT Scheme modules. Many systems follow the Lisp tradition of docstrings, in which documentation is associated to run-time values and used for online help. Python supports docstrings, and its doctest module even extracts and executes examples as tests, analogous to Scribble’s examples form. Scribble supports a docstring-like connection between run-time bindings and documentation, but using lexicalbinding information instead of the value associated with a binding. For example, (help cons) in PLT Scheme’s read-eval-print loop opens documentation for cons based on its binding as imported from scheme/base, and not based on the procedure obtained by evaluating cons. Smalltalk programming environments (Kay 1993) have always encouraged programmers to use the source (with its comments) as documentation, and environments like Eclipse and Visual Studio now make code navigation similarly convenient for other languages. Such tools do not supplant the need for external documentation, however, such as guides and tutorials. In terms of surface syntax, many documentation systems build on either S-expression notation (or its cousin XML) as a way to encode both document structure and program structure. Such representations are especially appropriate for an intermediate representation of documentation, as in DocBook (Walsh and Muellner 2008). S-expression encodings of documentation are especially common in Lisp projects, where data and code are mingled easily.
12.
Trying Scribble To install an HTML version of this paper where Scheme and Scribble identifiers are hyperlinked to their documentation, first install PLT Scheme 4.1.5 or later from http://plt-scheme.org/. Then, start DrScheme, enter the program #lang scheme (require (planet mflatt/scribble-paper))
and click Run. Running the program installs the paper and directs your default browser to the starting page. To view the document source, click Check Syntax and then right-click on mflatt/scribble-paper to open its source. Acknowledgements: We would like to thank Matthias Felleisen and the anonymous reviewers for helpful feedback on this paper. This work is supported in part by the NSF.
Bibliography R. Kent Dybvig, Robert Hieb, and Carl Bruggeman. Syntactic Abstraction in Scheme. Lisp and Symbolic Computation 5(4), pp. 295–326, 1993. Matthew Flatt. Compilable and Composable Macros, You Want it When? In Proc. ACM Intl. Conf. Functional Programming, pp. 72–83, 2002. Matthew Flatt, Robert Bruce Findler, Shriram Krishnamurthi, and Matthias Felleisen. Programming Languages as Operating Systems (or Revenge of the Son of the Lisp Machine). In Proc. ACM Intl. Conf. Functional Programming, pp. 138–147, 1999. Matthew Flatt, and PLT Scheme. Reference: PLT Scheme. PLT Scheme Inc., PLT-TR2009-reference-v4.2, 2009. Erick Gallesio, and Manuel Serrano. Skribe: a Functional Authoring Language. J. Functional Programming 15(5), pp. 751–770, 2005. Alan C. Kay. The early history of Smalltalk. ACM SIGPLAN Notices 28(3), 1993. Donald E. Knuth. Literate Programming. Computer Journal 27(2), pp. 97– 111, 1984.
Conclusion
A documentation language should be designed not by piling escape conventions on top of a comment syntax, but by removing the weaknesses and restrictions of the programming language that make a separate documentation language appear necessary. Scribble demonstrates that a small number of rules for forming documentation, with no restrictions on how they are composed, suffice to form a practical and efficient documentation language that is flexible enough to support the major documentation paradigms in use today.
Xavier Leroy. The Objective Caml System, release 3.10. 2007. Simon Marlow. Haddock, a Haskell Documentation Tool. In Proc. ACM Wksp. Haskell, pp. 78–89, 2002. Jacob Matthews. Component Deployment with PLaneT: You Want it Where? In Proc. Wksp. Scheme and Functional Programming, 2006. Norman Ramsey. Literate Programming Simplified. IEEE Software 11(5), pp. 97–105, 1994.
— Clinger’s introduction to the Rn RS standards, adapted for Scribble
Volker Simonis, and Roland Weiss. ProgDOC — A New Program Documentation System. In Proc. Perspectives of System Informatics, Lecture Notes in Computer Science volume 2890, pp. 438–449, 2003.
Our design for Scribble relies on a thread of language-extension work that starts in Lisp macros, runs through Scheme’s introduction of lexically scoped syntax, and continues with PLT Scheme innovations on modules, phases, and an open syntax expander. Meanwhile, LATEX and Skribe demonstrate the advantages of building a document system on top of a programming language, and tools like JavaDoc demonstrate the power of leveraging the information available in a program’s source to automate and link documentation about the program. Scribble combines all of these threads for the first time, producing a tool (or library, or language, depending on how you look at it) that spans and integrates document categories. We are aware
Dorai Sitaram. TeX2page. 2007. http://www.ccs.neu.edu/home/ dorai/tex2page/tex2page-doc.html Dorai Sitaram. How to Use SLaTeX. 2007. http://www.ccs.neu. edu/home/dorai/slatex/ Michael Sperber (Ed.). The Revised 6 Report on the Algorithmic Language Scheme. 2007. Norman Walsh, and Leonard Muellner. DocBook: The Definitive Guide. O’Reilly & Associates, Inc., 2008. Dimitri van Heesch. Doxygen Source Code Documentation Generator Tool. 2007. http://www.stack.nl/˜dimitri/doxygen/
120
Lambda, the Ultimate TA Using a Proof Assistant to Teach Programming Language Foundations Benjamin C. Pierce University of Pennsylvania http://www.cis.upenn.edu/∼bcpierce
Abstract Categories and Subject Descriptors mation Science Education]
Ambitious experiments using proof assistants for programming language research and teaching are all the rage. In this talk, I’ll report on one now underway at the University of Pennsylvania and several other sites: a one-semester graduate course in the theory of programming languages presented entirely—every lecture, every homework assignment—in Coq. I’ll try to give a sense of what the course is like for both instructors and students, describe some of the most interesting challenges in developing it, and explain why I now believe such machine-assisted courses are the way of the future.
General Terms
K.3.2 [Computer and Infor-
Languages, Theory
Keywords Programming languages, pedagogy, proof assistants
Copyright is held by the author/owner(s). ICFP’09, August 31–September 2, 2009, Edinburgh, Scotland, UK. ACM 978-1-60558-332-7/09/08.
121
A Universe of Binding and Computation Daniel R. Licata∗
Robert Harper ∗
Carnegie Mellon University {drl,rwh}@cs.cmu.edu
Abstract
functional programming, have an extensional character—a function from A to B is a black box that, when given an A, delivers a B. A function-as-data determines a function-as-computation by substitution (plugging a value in for a variable), but not every functionas-computation determines a function-as-data, because the syntax appropriate for a particular problem may not allow the expression of every black box. In previous work (Licata et al., 2008), we began to study a programming language that provides support for both functionsas-data and functions-as-computation as two different types. Our framework provides one type constructor ⇒ for functions-as-data, used to represent variable binding, and another type constructor ⊃ for functions-as-computation, used for functional programming. This permits representations that mix the two function spaces. As a simple example of such integration, consider a syntax for arithmetic expressions constructed out of (1) variables, (2) numeric constants, (3) let binding, and (4) arbitrary binary primitive operations, represented by functions-as-computation of type nat ⊃ nat ⊃ nat. In SML, we would represent this syntax with the following datatype:
We construct a logical framework supporting datatypes that mix binding and computation, implemented as a universe in the dependently typed programming language Agda 2. We represent binding pronominally, using well-scoped de Bruijn indices, so that types can be used to reason about the scoping of variables. We equip our universe with datatype-generic implementations of weakening, substitution, exchange, contraction, and subordination-based strengthening, so that programmers need not reimplement these operations for each individual language they define. In our mixed, pronominal setting, weakening and substitution hold only under some conditions on types, but we show that these conditions can be discharged automatically in many cases. Finally, we program a variety of standard difficult test cases from the literature, such as normalization-by-evaluation for the untyped λ-calculus, demonstrating that we can express detailed invariants about variable usage in a program’s type while still writing clean and clear code. Categories and Subject Descriptors F.3.3 [Logics and Meanings Of Programs]: Studies of Program Constructs—Type structure General Terms Languages, Verification
1.
datatype | | |
Introduction
There has been a great deal of research on programming languages for computing with binding and scope (bound variables, α-equivalence, capture-avoiding substitution). These languages are useful for a variety of tasks, such as implementing domain-specific languages and formalizing the metatheory of programming languages. Functional programming with binding and scope involves two different notions of function: functions-as-data and functionsas-computation. Functions-as-data, used to represent abstract syntax with variable binding, have an intensional, syntactic, character, in the sense that they can be inspected in ways other than function application. For example, many algorithms that process abstract syntax recur under binders, treating variables symbolically. On the other hand, functions-as-computation, the usual functions of
arith = Num Letbind Binop
Var of var of nat of arith * (var * arith) of arith * (nat -> nat -> nat) * arith
We use ML functions(-as-computation) to represent the primops. However, because SML provides no support for functions-as-data, we must represent variable binding explicitly (with a type var), and code notions such as α-equivalence and substitution ourselves. In contrast, our framework naturally supports mixed datatypes such as this one. We specify it by the following constructors: num letbind binop
: : :
arith ⇐ nat arith ⇐ arith ⊗ (arith ⇒ arith) arith ⇐ arith ⊗ (nat ⊃ nat ⊃ nat) ⊗ arith
The symbol ⇐ is used for datatype constructors, which have the form D ⇐ A, for a datatype name D and a type A. We use ⇒ (functions-as-data) to represent the body of the letbind, and ⊃ (functions-as-computation) to represent the primops. Our framework takes a pronominal approach to the variables introduced by functions-as-data: variables are thought of as pronouns that refer to a designated binding site, and thus are intrinsically scoped. This is in contrast to the nominal approach taken by languages such as FreshML (Pitts and Gabbay, 2000; Pottier, 2007; Shinwell et al., 2003), where variables are thought of as nouns— they are pieces of data that exist independently of any scope. The pronominal approach inherently requires some notion of context to be present in the language’s type system, so that variables have something to refer to; we write hΨi A as the classifier of a program of type A with variables Ψ. The practical advantage of these contextual types is that they permit programmers to express useful invariants about variable-manipulating code using the type system, such as the fact that a λ-calculus evaluator maps closed terms to closed terms.
∗ This
research was sponsored in part by the National Science Foundation under grant number CCF-0702381 and by the Pradeep Sindhu Computer Science Fellowship. The views and conclusions contained in this document are those of the author and should not be interpreted as representing the official policies, either expressed or implied, of any sponsoring institution, the U.S. government or any other entity.
Permission to make digital or hard copies of all or part of this work for personal or classroom use is granted without fee provided that copies are not made or distributed for profit or commercial advantage and that copies bear this notice and the full citation on the first page. To copy otherwise, to republish, to post on servers or to redistribute to lists, requires prior specific permission and/or a fee. ICFP’09, August 31–September 2, 2009, Edinburgh, Scotland, UK. c 2009 ACM 978-1-60558-332-7/09/08. . . $5.00 Copyright
123
exist. As a rough rule of thumb, one can weaken with types that do not appear to the left of a computational arrow in the type being weakened, and similarly for substitution. Our framework implements the structural properties generically but conditionally, providing programmers with the structural properties “for free” in many cases. This preserves one of the key benefits of working in LF, where weakening and substitution are always defined. In our previous work (Licata et al., 2008), we investigated the logical foundations of a pronominal approach to mixing binding and computation. In the present paper, we give an implementation of (a slight variant of) our framework, and we demonstrate the viability of our approach by programming some standard difficult test cases from the literature. For example, we implement normalization-by-evaluation (Berger and Schwichtenberg, 1991; Martin-Löf, 1975) for the untyped λ-calculus, an example considered in FreshML by Shinwell et al. (2003). Our version of this algorithm makes essential use of a datatype mixing binding and computation, and our type system verifies that evaluation maps closed terms to closed terms. Rather than implementing a new language from scratch, we construct our type theory as a universe in Agda 2 (Norell, 2007), a dependently typed functional programming language that provides good support for programming with inductive families, in the style of Epigram (McBride and McKinna, 2004). This means that we (a) give a syntax for the types of our type theory and (b) give a function mapping the types of our language to certain Agda types; the programs of our language are then the Agda programs of those types. This implementation strategy allows us to reuse the considerable implementation effort that has gone into Agda, and to exploit generic programming within dependently typed programming (Altenkirch and McBride, 2003) to implement the structural properties; additionally, it permits programs written using our framework to interact with existing Agda code. Also, our development provides a successful example of prototyping a new language with an interesting type system using a dependently typed programming language. In our Agda implementation, we have chosen to represent variable binding using well-scoped de Bruijn indices. In summary, we make the following technical contributions: (1) We show that our previous type theory for integrating binding and computation can be implemented as a universe in Agda. The types of the universe permit concise, “point-free” descriptions of contextual types: a type in the universe acts as a function from contexts to Agda types. (2) We implement a variety of structural properties for the universe, including weakening, substitution, exchange, contraction, and subordination-based strengthening (Virga, 1999), all using a single generic map function for datatypes that mix binding and computation. (3) We define the structural properties’ preconditions computationally, so that our framework can discharge these conditions automatically in many cases. This gives the programmer free access to weakening, substitution, etc. (when they hold). (4) We program a variety of examples, and demonstrate that we can express detailed invariants about variable usage in a program’s type while still writing clean and clear code. In this paper, we consider only a simply-typed universe, for writing ML-like programs that manipulate binding in a well-scoped manner; we leave dependent types to future work. Also, the companion code for this paper (see http://www.cs.cmu.edu/~drl/) is written in “Agda minus termination checking,” as many of our examples require non-termination; we discuss which parts of our code pass the termination checker below. The remainder of this paper is organized as follows: In Section 2, we introduce our language and its semantics in Agda. In Section 3, we present examples. In Section 4, we discuss the structural properties. In Sections 5 and 6, we discuss related work and conclude. Appendix A contains a brief introduction to Agda.
In a pronominal setting, the interaction of functions-as-data and functions-as-computation has interesting consequences for the structural properties of variables, such as weakening (introducing a new variable that does not occur) and substitution (plugging a value in for a variable). For example, one might expect that it would be possible to weaken a value of type A to a function-asdata of type D ⇒ A. However, this is not necessarily possible when A itself is a computational function type: Contextual computational functions of type hΨi A ⊃ B are essentially interpreted as functions from hΨi A to hΨi B , and hΨi D ⇒ A classifies values of type A in an extended context Ψ, x : D. Now, suppose we are given a function f of type hΨi A ⊃ B ; we try to construct a function of type hΨi D ⇒ (A ⊃ B ). This requires a function from hΨ, x : Di A to hΨ, x : Di B . Since f is a black box, we can only hope to achieve this by pre- and post-composing with appropriate functions. The post-composition must take hΨi B to hΨ, x : Di B , which would be a recursive application of weakening. However, the pre-composition has a contravariant flip: we require a strengthening function from hΨ, x : Di A to hΨi A in order to call f—and such a strengthening function does not in general exist, because the value of type A might be that very variable x. Similarly, substitution of terms for variables is not necessarily possible, because substitution requires weakening. Put differently, computational functions permit the expression of side conditions that inspect the context, which causes the structural properties to fail. As a concrete example, consider computational functions of type h·i (arith ⊃ arith), which are defined by case-analysis over closed arithmetic expressions, giving cases for constants and binops and let-binding—but not for variables, because there are no variables in the empty context. Weakening such a function to type arith ⇒ (arith ⊃ arith) enlarges its domain, asking it to handle cases that it does not cover. What are we to do about this interaction of binding and computation? One option is to work in a less general setting, where it does not come up. For example, in nominal languages such as FreshML (Shinwell et al., 2003), the type of names is kept openended (it is considered to have infinitely many inhabitants). Thus, any computational function on syntax with binding must account for arbitrarily many names, and is therefore weakenable. However, many functions on syntax are only defined for certain classes of contexts (e.g., only closed arithmetic expressions can be evaluated to a numeral), and the nominal approach does not allow these invariants to be expressed in a program’s type (though they can be reasoned about externally using a specification logic (Pottier, 2007)). Alternatively, in languages based on the LF logical framework, such as Twelf (Pfenning and Schürmann, 1999), Delphin (Poswolsky and Schürmann, 2008), and Beluga (Pientka, 2008), the structural properties always hold, because computational functions cannot be used in LF representations of logical systems. In our framework, we take a more general approach, which requires admitting that weakening and substitution may not always be defined. Thus, we should be more careful with terminology, and say that the type D ⇒ A classifies values of type A with a free variable of type D. In some cases, D ⇒ A determines a function given by substitution, but in some cases it does not. In this sense, our approach is similar to representations of binding using wellscoped de Bruijn indices (Altenkirch and Reus, 1999; Bellegarde and Hook, 1994; Bird and Paterson, 1999), which are pronominal, because variables are represented by pointers into a context, but make no commitment to weakening and substitution. However, our framework improves upon such representations by observing that weakening and substitution are in fact definable generically, not for every type D ⇒ A, but under certain conditions on the types D and A. For example, returning to our failed attempt to weaken A ⊃ B above, if variables of type D could never appear in terms of type A, then the required strengthening operation would
124
2.
Language Definition
The only subtlety in this definition is that we represent the bodies of ∀c and ∃c by computational functions in Agda. This choice 2.1 Types has some trade-offs: on the one hand, it means that the bodies of The grammar for the types of our language is as follows: quantifiers can be specified by any Agda computation (e.g. by recursion over the domain). On the other hand, it makes it difficult to analyze the syntax of Types, because there is no way to inspect Defined atoms D ::= . . . the body of the quantifier. Indeed, this caused problems for our Var. Types C ::= (a subset of D) implementation of the structural properties, which we solved by Contexts Ψ ::= [] | (Ψ, C) Types A ::= 0+ | 1+ | A ⊗ B | A ⊕ B | listA | A ⊃ B adding certain instances of the quantifiers (∀⇒ and ∃⇒, discussed below), which would otherwise be derived forms, as separate Type D+ D | C# | Ψ ⇒∗ A | 2A constructors. In future work, we may pursue a more syntactic treat∀c ψ.A | ∃c ψ.A | ∀≤C A | ∃≤C A ment of the quantifiers (which would of course be easier if we had The language is parametrized by a class of defined atoms D, good support for variable binding. . . ). which are the names of datatypes. A subset of these names are A rule, which is the type of a datatype constructor, pairs variable types, which are allowed to appear in contexts. This disthe defined atom being constructed with a single premise type tinguishes certain types C which may be populated by variables (no/multiple premises can be encoded using 1+ and ⊗): from other types D which may not. This definition of VarType permits only variables of base type, rather than the full language data Rule : Set where of higher-order rules that we considered in previous work (Licata _⇐_ : DefAtom -> Type -> Rule et al., 2008). Contexts are lists of variable types, written with ’cons’ We will make use of a few derived forms: on the right. The types on the first line have their usual meaning. The type • We write (∀⇒ A) for (∀c \Ψ -> Ψ ⇒* A), and similarly D+ D is the datatype named by D. Following Delphin (Poswolsky for ∃⇒ (note that \ x -> e introduces an anonymous funcand Schürmann, 2008), we include a type C# classifying only the tion). This type quantifies over a context Ψ and immediately variables of type C. The type Ψ ⇒∗ A classifies inhabitants of binds it around A. Similarly, we write [ Ψ ]* A for 2 (Ψ ⇒* A in the current context extended with Ψ. The type 2A classifies A) closed inhabitants of A. The types ∀c and ∃c classify universal • We write (C ⇒ Ψ) for ⇒* with a single premise. and existential context quantification; ∀≤C A and ∃≤C A provide bounded quantification over contexts containing only the type C. • We write (C + ) for (D+ C) when C is a variable type. 2.1.1
• We write bool for 1+ ⊕ 1+ and A option for A ⊕ 1+ .
Agda implementation
We now represent these types in Agda. Those readers who are not fluent in dependent programming can find a review of Agda syntax, well-scoped de Bruijn indices, and universes in Appendix A. We represent defined atoms, variable types and contexts as follows:
2.2
Semantics
A universe is specified by a inductive datatype of codes for types, along with a function mapping each code to a Set. In this case, the Types above are the codes, and the semantics is specified in Figure 1 by a function < Ψ > A, mapping a context and a Type to an Agda Set. The first six cases interpret the basic types of the simply-typed λ-calculus as their Agda counterparts, pushing the context inside to the recursive calls. The next two cases interpret datatypes. We define an auxiliary datatype called Data which represents all of the data types defined in the universe. Data is indexed by a context and a defined atom, with the idea that the Agda set Data Ψ D represents the values of datatype D in context Ψ. For example, the values of Data Ψ arith will represent the arithmetic expressions defined by the signature given in the introduction. There are two ways to construct a datatype: (1) apply a datatype constructor to an argument and (2) choose a variable from Ψ. Constants are declared in a signature, represented with a predicate on rules InΣ : Rule -> Set, where InΣ R is inhabited when the rule R is in the signature. The first constructor, written as infix ·, pairs a constant with the interpretation of the constant’s premise. The second constructor, ., injects a variable from Ψ into Data.1 See the appendix for the definition of the type ∈, which represents well-scoped de Bruijn indices (Altenkirch and Reus, 1999; Bellegarde and Hook, 1994; Bird and Paterson, 1999). A DefAtom D is in the context if there exist credentials c for which the VarType formed by (. D {c}) is in the list Ψ. Finally, we provide a collection of types that deal with the context: Ψ ⇒* A extends the context (we write + for append); 2 A clears the context. The quantifiers ∀c and ∃c are interpreted as the corresponding Agda dependent function and pair types. Finally,
DefAtom = DefinedAtoms.Atom data VarType : Set where . : (D : DefAtom) {_ : Check(DefinedAtoms.world D)} -> VarType Vars = List VarType
DefinedAtoms.Atom is a parameter that we will instantiate later. DefinedAtoms.world returns true when D is allowed to appear in the context; Check turns this boolean into a proposition (Check True is the unit type; Check False is the empty type; see Appendix A for an introduction). A VarType is thus a pair of an atom along with the credentials allowing it to appear in contexts. We represent the syntax of types in Agda as follows: data Type : Set where –– types that have their usual meaning 1+ : Type _⊗_ : Type -> Type -> Type 0+ : Type _⊕_ : Type -> Type -> Type list_ : Type -> Type _⊃_ : Type -> Type -> Type –– datatypes and context manipulation D+ : DefAtom -> Type _# : VarType -> Type _⇒*_ : Vars -> Type -> Type 2 : Type -> Type ∀c : (Vars -> Type) -> Type ∃c : (Vars -> Type) -> Type ∀≤ : VarType -> Type -> Type ∃≤ : VarType -> Type -> Type
1 Agda allows overloading of datatype constructors between different types,
and we tend to use . for injections from one type to another, as with VarType above.
125
AllEq : Vars -> VarType -> Set AllEq Ψ D = Check (List.all (eqVarType D) Ψ)
subst : (A : Type) {D : VarType} {_ : Check(canSubst (un. Cut) A)} -> (∀⇒ (D ⇒ A) ⊃ (D + ) ⊃ A)
mutual data Data (Ψ : Vars) (D : DefAtom) : Set where _·_ : {A : Type} -> InΣ (D ⇐ A) -> < Ψ > A -> Data Ψ D . : {c : _} -> (. D {c}) ∈ Ψ -> Data Ψ D
weaken : (A : Type) {D : VarType} {_ : Check (canWeaken (un. D) A)} -> (∀⇒ A ⊃ (D ⇒ A)) strengthen : (A : Type) {D : VarType} {_ : Check (canStrengthen (un. D) A)} -> ∀⇒ (D ⇒ A) ⊃ A
_ : Vars -> Type -> Set –– basic types < Ψ > 1+ = Unit < Ψ > 0+ = Void < Ψ > (A ⊗ B) = (< Ψ > A) × (< Ψ > B) < Ψ > (A ⊕ B) = Either (< Ψ > A) (< Ψ > B) < Ψ > (list A) = List (< Ψ > A) < Ψ > (A ⊃ B) = (< Ψ > A) -> (< Ψ > B) –– data types < Ψ > (D+ D) = Data Ψ D < Ψ > (D #) = D ∈ Ψ –– context manipulation < Ψ > (Ψnew ⇒* A) = < Ψ + Ψnew > A < _ > (2 A) = < [] > A < Ψ > (∃c τ ) = Σ \ Ψ’ -> < Ψ > (τ Ψ’) < Ψ > (∀c τ ) = (Ψ’ : Vars) -> < Ψ > (τ Ψ’) < Ψ > (∀≤ D A) = (Ψ’ : Vars) -> AllEq Ψ’ D -> < Ψ + Ψ’ > A < Ψ > (∃≤ D A) = Σ \ (Ψ’ : Vars) -> AllEq Ψ’ D × < Ψ + Ψ’ > A
exchange2 : (A : Type) {D1 D2 : VarType} -> (∀⇒ (D2 ⇒ D1 ⇒ A) ⊃ (D1 ⇒ D2 ⇒ A)) contract2 : (A : Type) {D : VarType} -> (∀⇒ (D ⇒ D ⇒ A) ⊃ (D ⇒ A)) weaken*/bounded : (A : Type) (Ψ : Vars) {D : VarType} -> (AllEq Ψ D) -> {canw : Check (canWeaken (un. D) A)} -> (∀⇒ A ⊃ (Ψ ⇒* A))
Figure 2. Type signatures of structural properties 2.3
Structural Properties
In Figure 2, we present the type signatures for the structural properties; this is the interface that users of our framework see. For example, the type of substitution should be read as follows: for any A and D, if the conditions for substitution hold, then there is a function of type (∀⇒ (D ⇒ A) ⊃ (D + ) ⊃ A) (for any context, given a term of type A with a free variable, and something of type D + to plug in, there is a term of type A without the free variable). Weakening coerces a term of type A to a term with an extra free variable; strengthening does the reverse; exchange swaps two variables; contraction substitutes a variable for a variable. We also include an n-ary version of weakening for use with the bounded quantifier: if A can be weakened with D, then A can be weakened with a whole context comprised entirely of occurrences of D. We discuss the meaning of the conditions (canSubst, etc.) below; in all of our examples, they will be discharged automatically by our implementation.
Figure 1. Semantics the types ∀≤ D A and ∃≤ D A quantify over contexts Ψ’ for which AllEq Ψ’ D holds. The type AllEq says that every variable type in Ψ is equal to the given type D (List.all is true when its argument is true on all elements of the list; eqVarType is a boolean-valued equality function for variable types). (We could internalize AllEq Ψ’ D as a type alleq D—given meaning by < Ψ > (alleq D) = AllEq Ψ D—in which case the bounded quantifier could expressed as a derived form, but we have not needed alleq D in a positive position in the examples we have coded so far.) An Agda datatype is strictly positive if it does not appear to the left of any Agda function types (->) in its own definition; this positivity condition ensures that the user does not define general recursive types (e.g. µD.D → D), which can be used to inhabit any type and to write non-terminating code. The above type Data does not pass the positivity checker: it is defined mutually with _, and _ occurs to the left of an Agda function type in the meaning of ⊃. In this paper, we wish to program with general recursive types, so we will ignore this failure of positivity checking. An interesting direction for future work would be to consider a total variant of our framework, which admits only strictly positive types. This would require a more refined explanation of the construction of the defined atoms in the universe, e.g. using containers (Abbott et al., 2005), because the positivity of a defined atom D depends on the rules for D in the signature InΣ. We also define versions of 2 and ∀⇒ that construct Agda Sets, so that we do not need to write < [] > 2 A and so on as the Agda type of a term. (We intentionally use a very similar notation for these; to a first approximation, one can read our examples without keeping this distinction in mind.)
3.
Examples
In this section, we illustrate programming in our framework, adapting a number of examples that have been considered in the literature (Pientka, 2008; Poswolsky and Schürmann, 2008; Shinwell et al., 2003). Throughout this section, we compare the examples coded in our framework with how they are/might be represented in Twelf, Delphin, Beluga, and FreshML. We endeavor to keep these comparisons objective, focusing on what invariants of the code are expressed, and what auxiliary functions the programmer needs to define. Aside from Twelf, we are not expert users of these other systems, and we welcome feedback from those who are. Several additional examples are available in the companion Agda code, including a translation from λ-terms to combinators, a type checker for simply-typed λ-calculus terms, an evaluator for λ-calculus with mutable references (using variables to represent locations), and an alternate version of normalization-by-evaluation, which has simpler types at the expense of slightly more-complicated code. To use our framework, we give a type DefAtom representing the necessary datatypes names, along with a datatype
: Type -> Set A = < [] > A
data InΣ : Rule -> Set where
∀⇒ _ : Type -> Set ∀⇒ _ A = (Ψ : Vars) -> < Ψ > A
defining the datatype constructors.
126
We use the following naming convention: Defined atoms are given names that end in A; e.g., for the signature for arithmetic expressions given in the introduction, we will define natA and arithA. For types of variables, we define C to be A injected into VarType:
checker, which recognizes certain substitution instances as smaller. We have not yet investigated how to explain this induction principle to Agda. 3.2
arithC = . arithA
We define to be the Type constructed by D+ A; e.g.: nat = D+ natA arith = D+ arithA
3.1
lam app
Evaluating Arithmetic Expressions
: InΣ (expA ⇐ (expC ⇒ exp)) : InΣ (expA ⇐ exp ⊗ exp)
closure : InΣ (closA ⇐ (∃⇒ (expC ⇒ exp) ⊗ (expC # ⊃ 2 clos)))
We define a signature for the arithmetic example mentioned above: zero : InΣ (natA ⇐ 1+ ) succ : InΣ (natA ⇐ nat) num : InΣ (arithA ⇐ nat) letbind : InΣ (arithA ⇐ arith ⊗ (arithC ⇒ arith)) binop : InΣ (arithA ⇐ arith ⊗ (nat ⊃ nat ⊃ nat) ⊗ arith)
Natural numbers are specified by zero and successor. Arithmetic expressions are given as a mixed datatype, with ⇒ used to represent the body of the letbind and ⊃ used to represent primops. Next, we define an evaluation function that reduces an expression to a number: eval eval eval eval eval
Closure-based Evaluator
Next, we implement a closure-based evaluator for the untyped λcalculus. λ-terms and closures are represented by types exp and clos as follows:
: (arith ⊃ nat) (num · n) = n (letbind · (e1 , e2)) = eval (subst arith _ e2 e1) (binop · (e1 , f , e2)) = f (eval e1) (eval e2) (. ())
Evaluation maps closed arithmetic expressions to natural numbers (the type expression (arith ⊃ nat) reduces to the Agda function type Data [] arithA → Data [] natA). Constants evaluate to themselves; binops are evaluated by applying their code to the values of the arguments; let-binding is evaluated by substituting the expression e1 into the letbind’s body e22 and then evaluating the result. A simple variation would be to evaluate e1 first and then substitute its value into e2. The final clause covers the case for variables with a refutation pattern: there are no variables in the empty context.
Expressions are defined by the usual signature, as in LF. The type of closures, clos, is a recursive type with one constructor closure. The premise of closure should be read as follows: a closure is constructed from a triple (Ψ , e , σ), where (1) Ψ is an existentially quantified context; (2) e is an expression in Ψ with an extra free variable, which represents the body of a λ-abstraction; and (3) σ is a substitution of of closed closures for all the variables in Ψ. We represent a substitution as a function that maps each expression variable in the context (classified by the type expC #) to a closure. The type of the premise provides a succinct description of all of this: ∃⇒ introduces the variables in the existentially quantified context into scope without explicitly naming the context; ⇒ extends the context with an additional variable; (expC #) ranges over all of the variables in scope. For comparison, in Ψ this type reduces to the Agda type Σ \(Ψ’ : Vars) -> (Data (Ψ + Ψ’ „ expC) expA) × (expC ∈ (Ψ + Ψ’) -> Data [] closA)
(where we write „ for cons on the right). In this example, unlike the above evaluator for closed arithmetic expressions, we recur over open expressions, so eval is quantified over an unknown context Ψ using ∀⇒. Evaluation takes two further arguments: (1) an expression with free variables in Ψ, and (2) an environment, represented by a function that yields a closed closure for each expression variable in Ψ; eval returns a closed closure. env : Type env = expC #
Comparison. This example provides a nice illustration of the benefits of our approach: Substitution is provided “for free” by the framework, which infers that it is permissible to substitute for arithC variables in arith. The type system enforces the invariant that evaluation produces a closed natural number. It is not possible to define the type arith in Twelf/Delphin/Beluga, as LF representations cannot use computational functions. One could program this example in FreshML, but it would be necessary to implement substitution directly for arith, as FreshML does not provide a generic substitution operation. Agda checks that eval’s pattern matching is exhaustive. However, Agda is not able to verify the termination of this function, as it recurs on a substitution-instance of one of the inputs. Setting aside the computational functions in binop, it would be possible to get the call-by-value version of this code to pass Twelf’s termination 2 The arith argument to subst is the type A in the D ⇒ A argument to substitution; Agda’s type reconstruction procedure requires this annotation. The underscore is the context argument instantiating the ∀⇒ in the type of subst; this could be eliminated by adding an implicit context quantifier (whose meaning is { Ψ : Vars } -> ...) to the universe. The credentials for performing substitution are marked as an implicit argument, so there is no evidence of it visible in the call to subst.
⊃
2 clos
eval : (∀⇒ exp ⊃ env ⊃ 2 clos) eval Ψ (. x) σ = σ x eval Ψ (lam · e) σ = closure · (Ψ , e , σ) eval Ψ (app · (e1 , e2)) σ with eval Ψ e1 σ ... | closure · (Ψ’ , e’ , σ’) = eval (Ψ’ „ expC) e’ (extend{(2 clos)} _ σ’ (eval Ψ e2 σ)) ... | . x = impossible x
A variable is evaluated by applying the substitution. A lam evaluates to the obvious closure. To evaluate an application, we first evaluate the function position. To a first approximation, the reader may think of Agda’s with syntax as a case statement in the body of the clause, with each branch marked by ... |. Case-analyzing the evaluation of e1 gives two cases: (1) the value is constructed by the constructor closure; (2) the value is a variable. In the first case, we evaluate the body of the closure in an extended environment. The call to the function extend extends the environment σ’ so that the last variable is mapped to the value of e2. The definition of extend is as follows: extend : {A : Type} {D : VarType} -> (∀⇒ (D # ⊃ A) ⊃ A ⊃ (D ⇒ D #) ⊃ A) extend Ψ σ new i0 = new extend Ψ σ new (iS i) = σ i
127
At the call site of extend, we must explicitly supply the type A (in this case 2 clos) to help out type reconstruction. The underscore stands for the instantiation of the ∀⇒ , which is marked as an explicit argument, but can in this case be inferred. The second case is contradicted using the function impossible, which refutes the existence of a variable at a non-VarType—which clos is, because we never wish to have clos variables. The context argument Ψ to eval does not play an interesting role in the code, but Agda’s type reconstruction requires us to supply it explicitly at each recursive call. In future work, we may consider whether this argument can be inferred. Agda is unable to verify the termination of this evaluator for the untyped λ-calculus, as one would hope. When writing this code, one mistake a programmer might make is to evaluate the body of the closure in σ instead of σ’, which would give dynamic scope. If we make this mistake, Agda highlights the occurrence of σ and helpfully reports the type error that Ψ’ != Ψ, indicating that the context of the expression does not match the context of the substitution.
The type of size expresses that it returns a closed natural number. For comparison, we implement a second version that does not make this invariant explicit: : (∀≤ expC (exp ⊃ nat)) Ψ bound (. x) = succ · (zero · _) Ψ bound (app · (e1 , e2)) = · (plus’ Ψ bound (size’ Ψ bound e1) (size’ Ψ bound e2)) where plus’ : (∀≤ expC (nat ⊃ nat ⊃ nat)) plus’ Ψ b = weaken*/bounded (nat ⊃ nat ⊃ nat) Ψ b [] plus size’ Ψ bound (lam · e) = strengthen nat _ (size’ (Ψ „ expC) bound e)
size’ size’ size’ succ
Comparison. In Twelf, one cannot represent substitutions σ using computational functions, because these are not available for use in LF encodings. However, because the domain of the substitution is finite, a first-order representation of substitutions could be used. Additionally, Twelf does not provide the 2 and ∃⇒ connectives that we use here to describe the contexts of closures. While it should be possible for the programmer to express the necessary context invariants using explicit contexts (Crary, 2008), this is a fairly heavy encoding technique. Because of these two limitations, the resulting Twelf code would be more complicated than the above. One would hope for better Delphin and Beluga implementations than a port of the Twelf code, but Delphin lacks existential context quantification and 2, and Beluga lacks the parameter type exp #, so our definition of clos cannot be straightforwardly ported to either of these languages.3 One could implement this example in FreshML (Shinwell et al., 2003), but the type system would not enforce the invariant that closures are in fact closed. To our knowledge, a proof of this property for this example has not been attempted in Pure FreshML (Pottier, 2007), though we know of no reason why it would not be possible.
Without the 2, size must return a number in context Ψ: in the application case, we must weaken plus into Ψ, and in the lam case we must strengthen the extra expC variable out of the recursive call. Strengthening expression variables from natural numbers is permitted by our implementation of the structural properties because natural numbers cannot mention expressions; we use a subordinationlike analysis to determine this (Virga, 1999). To ensure that these weakenings and strengthenings are permitted, we type size’ with a bounded quantifier over exp. Comparison. The first version is similar to what one writes in FreshML, except in that setting there is no need to pass around a context Ψ. In the second version, the strengthening of the recursive result in the lam case is analogous to the need, in FreshML 2000 (Pitts and Gabbay, 2000), to observe that nat is pure (always has empty support); FreshML (Shinwell et al., 2003) does not require this. In Beluga, one can express either the first or second versions. In Twelf and Delphin, one can only express the second variation, as these languages do not provide 2. However, the Twelf/Delphin/Beluga syntax for weakening and strengthening is terser than what we have been able to construct in Agda: weakening is handled by world subsumption and is not marked in the proof term; strengthening is marked by pattern-matching the result of the recursive call and marking those variables that do occur, which in this case does not include the expression variable. For example, the lam case of size in Twelf looks like this:
3.3
- : size (lam ([x] E x)) (succ N) exp) in Twelf, we plan to consider a derived induction principle that covers this case in future work.
Agda successfully termination-checks these functions. 3 Beluga
provides a built-in type of substitutions, written [Ψ’]Ψ, so one might hope to represent closures as ∃ψ.([ψ, x : exp]exp) × [.]ψ; however, the second component of this pair associates an expression with each expression variable in ψ, whereas, in this example, we need to associate a closure with each expression variable in ψ.
128
Comparison. Pattern-matching on variables is represented using higher-order metavariables in Twelf/Delphin/Beluga and using equality tests on names in FreshML. The exchange needed in the lam case is written as a substitution in the Twelf/Delphin/Beluga version of this clause. In Twelf one would write:
The metavariable F:exp is bound outside the scope of x, and thus stands only for terms that do not mention x. (To allow it to mention x, we would bind F:exp -> exp and write (F x) in place of F.) Unfortunately, Agda does not provide this sort of pattern matching for our encoding—pattern variables are always in the scope of all enclosing local binders—so we must explicitly call a strengthening function that checks whether the variable occurs:
- : cnt ([x] lam ([y] E x y)) N (∀⇒ (D ⇒ list remove Ψ [] = remove Ψ (i0 :: ns) = remove Ψ ((iS i) :: ns) =
fvs fvs fvs fvs
contract-η : ∀⇒ exp ⊃ exp option contract-η Ψ (lam · (app · (f , . i0))) = strengthen? Ψ f contract-η Ψ _ = Inr
We conjecture that strengthen? could be implemented datatypegenerically for all purely positive types (no ⊃ or ∀c or ∀≤)—it is not possible to decide whether a variable occurs in the values of these computational types (cf. FreshML, where it is not possible to decide whether a name is in the support of a function). This strengthening function is not an instance of the generic map that we define below, as it changes the type of the term (exp to exp option); in future work, we plan to consider a more general traversal that admits this operation.
(D #)) ⊃ list (D #)) [] (remove Ψ ns) i :: (remove Ψ ns)
: (∀⇒ exp ⊃ list (expC #)) Ψ (. x) = [ x ] Ψ (lam · e) = remove Ψ (fvs (Ψ „ expC) e) Ψ (app · (e1 , e2)) = (fvs Ψ e1) ++ (fvs Ψ e2)
3.4
In the lam case, we use the helper function remove to remove the lam-bound variable from the recursive result. The function remove takes a list of variables, itself with a distinguished free variable, and produces a list of variables without the distinguished variable. If the programmer were to make a mistake in the second clause by accidentally including i0 in the result, he would get a type error. Agda successfully termination-checks this example. Comparison. For comparison with FreshML (Shinwell et al., 2003), the type given to remove here is analogous to their Figure 6:
([[A arrow B]] in Ψ) = ∀Ψ0 . ([[A]] in Ψ,Ψ0 ) ⊃ ([[B]] in Ψ,Ψ0 ) That is, the meaning of A arrow B in Ψ is a function that, for any future extension of the context, maps the meaning of A in that extension to the meaning of B in that extension. In our type theory, we represent (a simply-typed version of) this logical relation as a datatype sem. The datatype constructor corresponding to the above clause would have the following type:
remove : ( (name list)) -> name list
where τ is a nominal abstractor. The authors comment that they prefer the version of remove in their Figure 5: remove : name -> (name list) -> name list
where the name to removed is specified by the first argument, rather than using a binder. Using dependent types, we can type this second version of remove as follows:
sem ⇐ (∀⇒ sem ⊃ sem)
However, for the argument to go through, we must ensure that the context extension Ψ’ consists only of variables of a specific type neu, so we use a bounded context quantifier below. We represent the semantics by the datatypes neu and sem in Figure 3. The type neu (neutral terms) consists of variables or neutral terms applied to semantic arguments (napp); these are the standard neutral proofs in natural deduction. A sem (semantic term) is either a neutral term or a semantic function. A semantic function of type (∀≤ neuC (sem ⊃ sem)) is a computational function that works in any extension of the context consisting entirely of neu variables. We define reification first, via two mutually recursive functions, reifyn (for neutral terms) and reify (for semantic terms). It is typical in logical relations arguments to use two independent contexts, one for the syntax and one for the semantics. Thus, we
remove : (Ψ : Vars) (i : exp ∈ Ψ) -> List (exp ∈ Ψ) -> List (exp ∈ (Ψ - i))
where Ψ - i removes the indicated element element from the list. This type is of course expressible in Agda, but we have not yet integrated dependent types into our universe. 3.3.4
Normalization by Evaluation
In Figure 3, we present a serious example mixing binding and computation, β-normalization-by-evaluation for the untyped λcalculus. NBE works by giving the syntax a semantics in terms of computational functions (evaluation) and then reading back a normal form (reification). The NBE algorithm is similar to a Kripke logical relations argument, where one defines a type- and context-indexed family of relations [[A]] in Ψ. The key clause of this definition is:
η-Contraction
In Twelf/Delphin/Beluga, one can recognize η-redices by writing a meta-variable that is not applied to all enclosing locally bound variables. E.g. in Twelf one would write - : contract (lam [x] app F x) F.
129
napp : InΣ (neuA ⇐ neu ⊗ sem) neut : InΣ (semA ⇐ neu) slam : InΣ (semA ⇐ (∀≤ neuC (sem ⊃ sem)))
mantic context is extended with a new neu variable. We instantiate the semantic function ϕ, which anticipates extensions of the context, with this one-variable extension ([ x ] constructs a singleton list), and apply it to the variable. The library function extendv2v makes the "parallel" extension of a var2var in the obvious way, mapping the one new variable to the other:
reifyn : ∀⇒ ∀c \ Ψs -> (var2var neuC Ψs expC) ⊃ [ Ψs ]* neu ⊃ exp reifyn Ψe Ψs σ (. x) = . (σ x) reifyn Ψe Ψs σ (napp · (n , s)) = app · (reifyn Ψe Ψs σ n , reify Ψe Ψs σ s)
extendv2v : {D1 D2 : VarType} -> (Ψs : Vars) -> ∀⇒ (var2var D1 Ψs D2) ⊃ D2 ⇒ (var2var D1 (D1 :: Ψs) D2) extendv2v Ψs Ψe σ (i0) = i0 extendv2v Ψs Ψe σ (iS i) = iS (σ i)
reify : ∀⇒ ∀c \ Ψs -> (var2var neuC Ψs expC) ⊃ [ Ψs ]* sem ⊃ exp reify Ψe Ψs σ (slam · ϕ) = lam · reify (Ψe „ expC) (Ψs „ neuC) (extendv2v Ψs Ψe σ) (ϕ [ neuC ] _ (neut · (. i0))) reify Ψe Ψs σ (neut · n) = reifyn Ψe Ψs σ n reify Ψe Ψs σ (. x) = impossible x appsem appsem appsem appsem
: _ _ _
∀⇒ sem ⊃ sem ⊃ sem (slam · ϕ) s2 = ϕ [] _ s2 (neut · n) s2 = neut · (napp · (n , s2)) (. x) _ = impossible x
evalenv : Vars -> Type evalenv Ψs = (expC #) ⊃ ([ Ψs ]* sem) eval : ∀⇒ ∀c \Ψs -> evalenv Ψs ⊃ exp ⊃ ([ Ψs ]* sem) eval Ψe Ψs σ (. x) = σ x eval Ψe Ψs σ (app · (e1 , e2)) = appsem Ψs (eval Ψe Ψs σ e1) (eval Ψe Ψs σ e2) eval Ψe Ψs σ (lam · e) = slam · ϕ where ϕ : < Ψs > (∀≤ neuC (sem ⊃ sem)) ϕ Ψ’ ctxinv s’ = eval (Ψe „ expC) (Ψs + Ψ’) σ’ e where σ’ : < Ψe > (expC ⇒ (evalenv (Ψs + Ψ’))) σ’ i0 = s’ σ’ (iS i) = weaken*/bounded sem Ψ’ ctxinv Ψs (σ i)
Figure 3. Normalization by evaluation parametrize these functions by two contexts, one consisting for neu variables for the semantics, and the other consisting of exp variables for the syntax. We will write Ψs for the former and Ψe for the latter. In the type of reify, we must name one of these contexts, because each context scopes over two disconnected parts of the type. We choose to name the semantic context and let the expression context be the ambient one. The outer ∀⇒ thus binds the expression context, whereas we use the full binding form ∀c for the semantic context. The type of reify then says that, under some condition expressed by the type var2var, reify maps semantics in the semantic context (recall that [ Ψ ]* A stands for 2 (Ψ ⇒* A); lexically, [ Ψ ]* A binds more tightly than ⊃) to expressions (in the ambient expression context). The type var2var C1 Ψ1 C2 means that every variable of type C1 in Ψ1 maps to a variable of type C2 in the ambient context. It is defined in a library as follows:
The neutral-to-semantic coercion is reified recursively, and we disallow sem variables from the context. To define evaluation, we first define an auxiliary function appsem that applies one semantic term to another. This requires a case-analysis of the function term: when it is an slam (i.e. the application is a β-redex), we apply the embedded computational function, choosing the nil context extension, and letting the argument be s2. When the function term is neutral, we make a longer neutral term. The type of eval is symmetric to reify, except the environment that we carry along in the induction maps expression variables to semantic terms rather than just variables. The type evalenv Ψs means that every expression variable in the ambient context is mapped to a semantic value in Ψs. A variable is evaluated by looking it up; an application is evaluated by combining the recursive results with semantic application. A lam is evaluated to an slam whose body ϕ has the type indicated in the figure. When given a context extension Ψ’ and an argument s’ in that extension, ϕ evaluates the original body e in an extended substitution. The new substitution σ’ maps the λ-bound variable i0 to the provided semantic value, and defers to σ on all other variables. However, σ provides values in Ψs, which must be weakened into the extension Ψ’. Fortunately, the bounded quantifier provides sufficient evidence to show that weakening can be performed in this case, because sem’s can be weakened with neu variables. Normalization is defined by composing evaluation and reification. We define a normalizer for closed λ-terms as follows: emptyv2v : emptyevalenv :
(var2var neuC [] expC) (evalenv [])
norm : (exp ⊃ exp) norm e = reify [] [] emptyv2v (eval [] [] emptyevalenv e)
Our type system has verified the scope-correctness of this code, proving that it maps closed terms to closed terms. Amusingly, Agda accepts the termination of this evaluator for the untyped λcalculus, provided that we have told it to ignore its issues with our universe itself—a nice illustration of the need for the positivity check on datatypes. Our companion code includes an alternate version of NBE, which has simpler types (it does not maintain separate contexts Ψe for expressions and Ψs for semantics) at the expense of more-complicated code (various appeals to weakening and strengthening are necessary). Comparison. The type sem is a truly mixed datatype: the premise (∀≤ neuC (sem ⊃ sem)) uses both ⇒ and ⊃ (recall that there is a ⇒ buried in the definition of ∀≤). Because it uses ⊃ in a recursive datatype, it is not representable in LF. Because it uses ⇒, it would not even be representable in Delphin/Beluga extended with standard recursive types (that did not interact with the LF part of the language). Despite the fact that our implementation enforces strong invariants about the scope of variables, the code is essentially as simple as the FreshML version described by Shinwell et al. (2003), aside from the need to pass the contexts Ψe and Ψs along. Invariants about variable scoping can be proved in Pure FreshML (Pot-
var2var : VarType -> Vars -> VarType -> Type var2var C1 Ψ1 C2 = ([ Ψ1 ]* (C1 #)) ⊃ (C2 #)
Even though reify is given a precise type describing the scoping of variables, its code is as simple as one could want. To reify neutral terms: The reification of a variable is the variable given in the substitution. The reification of an application is the application of the reifications. To reify semantic terms: The reification of a function (slam · ϕ) is the λ-abstraction of the reification of an instance of ϕ. In the recursive call, the expression context is extended with a new exp variable (which is bound by the lam) and the se-
130
map : (A : Type) {Ψ Ψ’ : Vars} -> (Co A Ψ Ψ’) -> < Ψ > A -> < Ψ’ > A map (D+ Dat) co (. x) = (snd (compat co) x) map (Dat #) co x = ((compat co) x) map (A ⊃ B) co e = \ y -> (map B (snd (compat co)) (e (map’ A (fst (compat co)) y))) map (Ψ0 ⇒* A) co e = map A (compat co) e map (list A) co [] = [] map (list A) co (x :: xs) = map A (compat co) x :: map (list A) co xs map (D+ Dat) co (_·_ {A} c e) = c · map A (fst (compat co) c) e map (2 A) co e = e –– ... more cases
tier, 2007), but we would like to enforce these invariants within a type system, not using an external specification logic. Relative to a direct implementation in Agda, our framework provides the weakening function needed in the final case of eval for free.
4.
Structural Properties
The structural properties are implemented by instantiating a generic traversal for < Ψ > A. The generic traversal has the following type: map : (A : Type) {Ψ Ψ’ : Vars} -> (Co A Ψ Ψ’) -> < Ψ > A
->
< Ψ’ > A
This should be read as follows: for every A Ψ Ψ’, under the condition Co A Ψ Ψ’, there is a map from terms of type A in Ψ to terms of type A in Ψ’. Co : Type -> Vars -> Vars -> Set is a variable relation, a type-indexed family of relations between two contexts. Co is in fact a (module-level) parameter to the generic map; it must provide (1) a variable or term in Ψ’ for each variable in Ψ that the traversal runs into; and (2) enough information to keep the traversal going inductively. We will instantiate Co with a specific relation for each traversal; e.g., for weakening with a variable of type D, Co will relate Ψ to (Ψ „ D) under appropriate conditions on D and A. For expository purposes, we present a slightly simplified version of the traversal first; the generalization is described with weakening below. 4.1
Figure 4. Map (excerpt) types ensure that the traversal will only find certain variables, and thus that only those variables need realizations. Compatibility ensures that Co provides enough information for Contra to process the contravariant positions to the left of a computational arrow. Additionally, it permits conditional traversals: below, we will instantiate Co so that it is uninhabited for certain A. 4.2
Map
Suppose that Co and Contra are compatible, and assume a function map’ : (A : Type) {Ψ Ψ’ : Vars} -> (Contra A Ψ Ψ’) -> < Ψ > A -> < Ψ’ > A
Compatibility
We ensure that Co provides the two pieces of information mentioned above using the notion of compatibility. Suppose that Co and Contra are variable relations. We say that Co and Contra are compatible iff there is a term
that is the equivalent of map for the Contravariant positions. Then we implement map in Figure 4. In the first and second cases, the compatibility of Co induces the map on variables that we need. In the third case, we pre-compose the function with map’ and post-compose with map. In all other cases, map simply commutes with constructors, or stops early if it hits a boxed term.
compat : ({A : Type} {Ψ Ψ’ : Vars} -> Co A Ψ Ψ’ -> Compat A Ψ Ψ’)
4.3
where Compat is defined as follows:
Exchange/Contraction
Exchange and contraction are implemented by one instantiation of map. In this case, we take
Compat : Type -> Vars -> Vars -> Set Compat (D #) Ψ Ψ’ = (D ∈ Ψ) -> (D ∈ Ψ’) Compat (D+ D) Ψ Ψ’ = ({A : Type} -> (c : InΣ (D ⇐ A)) -> Co A Ψ Ψ’) × ({ch : _} -> (. D {ch}) ∈ Ψ -> < Ψ’ > D+ D) Compat (A ⊃ B) Ψ Ψ’ = Contra A Ψ’ Ψ × Co B Ψ Ψ’ Compat (Ψ0 ⇒* A) Ψ Ψ’ = Co A (Ψ + Ψ0) (Ψ’ + Ψ0) Compat (list A) Ψ Ψ’ = Co A Ψ Ψ’ Compat (2 A) Ψ Ψ’ = Unit –– ...
Co A Ψ Ψ’ = Contra A Ψ Ψ’ = (Ψ ⊆ Ψ’ × Ψ’ ⊆ Ψ)
where ⊆ means every variable in one context is in the other. It is simple to show that these relations are compatible, because Co (a) provides the required action on variables directly and (b) ignores its type argument, so the compatibility cases for the type constructors are easy. Exchange is defined by instantiating the generic map with Co, where map’ is taken be map itself, which works because Co = Contra.
Compat imposes certain conditions on Co and Contra. For example, for variable types D #, it says that Co (D #) Ψ Ψ’ induces a map from variables of type D in Ψ to variables in Ψ’. For defined atoms D+ D, Compat says that Co (D+ D) Ψ Ψ’ induces a map from variables in Ψ to terms in Ψ’, and that Co A Ψ Ψ’ holds for every premise A of every constant inhabiting D. In all other cases, Compat provides enough information to keep the induction going in map below. This amounts to insisting that Co (or Contra) holds on the subexpressions of a type in all appropriate contexts. For example, the condition for Ψ0 ⇒* A is that Co holds for A in the contexts extended with Ψ0. In the usual monadic traversals of syntax (Altenkirch and Reus, 1999), Co _ Ψ Ψ’ is taken to be (D : VarType) -> D ∈ Ψ -> < Ψ’ > D—i.e. a realization of every variable in Ψ as a term in Ψ’. In our setting, this does not suffice to define a traversal, because (1) it does not provide for the contravariant flip necessary to process the domains of computational functions and (2) it does not allow us to express a conditional traversal, where conditions on the
4.4
Strengthening
Next, we define a traversal that strengthens away variables that, based on type information, cannot possibly occur. The invariant for strengthening is the following:4 Co : Type -> Vars -> Vars -> Set Co A Ψ Ψ’ = Σ \(D : VarType) -> Σ \(i : D ∈ Ψ) -> Check(irrel (un. D) A) × Id Ψ’ (Ψ - i)
Here i, a pointer into the initial context Ψ is the variable to be strengthened away; the propositional equality constraint represented by the Identity says that the final context Ψ’ is the initial context with i removed. The type Check(irrel (un. D) A) 4 For
concision, we suppress some details arising from the implementation of irrel, which takes a visited list as an extra argument; see the companion code for details.
131
types bound by A, so that the term being plugged in for the variable can be weakened as substitution goes under binders. Substitution takes about 220 lines to define and prove compatible.
computes to Unit when strengthening is possible, and Void when it is not. Here un. simply peels off the injection of a defined atom into a VarType. The crucial property of irrel is that Check(irrel (un. D) (D+ D)) computes to Void. This forbids strengthening a variable of type D out of a term of type D. This is necessary because we cannot satisfy the usual compatibility condition for (D+ D), which would require mapping all variables—including the variable-to-bestrengthened i—to a term of type D that does not mention i. More generally, Check(irrel (un. D) A) means that variables of type D can never be used to construct terms of type A, which ensures that strengthening never runs into variables of the type being strengthened. The function irrel D A is defined by traversing the graph structure of types (i.e., it unrolls the definitions of defined atoms) and checks not (DefinedAtoms.eq D Dat) for each defined atom Dat it finds. To account for contravariance, we must define strengthening simultaneously with weakening by irrelevant assumptions, which is similar. About 250 lines of Agda code shows that these two relations together are compatible. Their traversals are then defined by instantiating map twice, mutually recursively—each is passed to the other as map’ for the contravariant recursive calls. 4.5
5.
We have provided comparisons with several other systems throughout the paper: Relative to LF-based systems such as Twelf (Pfenning and Schürmann, 1999), Delphin (Poswolsky and Schürmann, 2008), and Beluga (Pientka, 2008), our framework permits definitions that mix binding and computation; this is essential for defining the datatype sem in the NBE example. Relative to FreshML (Pottier, 2007; Shinwell et al., 2003), our framework enforces invariants about variable scoping in the type system. Such invariants can be proved in Pure FreshML (Pottier, 2007), but we would like to enforce these invariants within a type system, not using an external specification logic. Aydemir et al. (2008) provide a nice overview of various techniques that are used to implement variable binding, including named, de Bruijn, {locally / globally} {named /nameless}, and weak higher-order abstract syntax (Bucalo et al., 2006; Despeyroux et al., 1995). More recently, Chlipala (2008) has advocated the use of parametric higher-order abstract syntax. We have chosen well-scoped de Bruijn indices (Altenkirch and Reus, 1999; Bellegarde and Hook, 1994; Bird and Paterson, 1999) for our Agda implementation, a simple representation that makes the pronoun structure of variables explicit. It would be interesting to investigate whether any benefits can be obtained by implementing our universe with a different representation. Relative to these techniques for representing binding, the advantage of our framework is that it provides datatype-generic implementations of the structural properties, including substitution. Both the Hybrid frameworks (Ambler et al., 2002; Capretta and Felty, 2007; Momigliano et al., 2007), Hickey et al. (2006)’s work, and Lambda Tamer (Chlipala, 2007) describe languages or tools for specifying data with binding, providing generic implementations of the structural properties. However, to the best of our knowledge, these logical frameworks do not make the computational functions of the meta-language available for use in the framework (except inasmuch as they are, in some cases, used to represent binding itself). In contrast, our universe includes both ⇒ and ⊃. In this work, we have created a universe of contextual types in Agda. Contextual types appear in Miller and Tiu’s work (Miller and Tiu, 2003), as well as in contextual modal type theory (Nanevski et al., 2007). Miller and Tiu’s self-dual ∇ connective is closely related to ⇒, also capturing the notion of a scoped constant. However, the ∇ proof theory adopts a logic-programming-based distinction between propositions and types, and ∇ binds a scoped term constant in a proposition. In our setting, ⇒ allows the meaning of certain propositions (defined atoms) to vary. Fiore et al. (1999) and Hofmann (1999) give semantic accounts of variable binding. In a sense, the present paper gives a semantics for our type theory, where binding is represented by an indexed inductive definition. However, this semantics does not shed any new light on the datatype-generic definition of the structural properties; it would be interesting to explore a semantic characterization of the conditions under which weakening and substitution are definable.
Weakening
In addition to weakening by irrelevant types (e.g. weakening a nat with an exp), we can weaken by types that do not appear to the left of a computational arrow (e.g., weakening an exp with an exp). For a simple version of weakening, the variable relation is similar to strengthening, but uses a different computed condition, and flips the role of Ψ and Ψ’ (now Ψ’ is bigger): Co : Type -> Vars -> Vars -> Set Co A Ψ Ψ’ = Σ \(D : VarType) -> Σ \(i : D ∈ Ψ’) -> Check(canWeaken (un. D) A) × Id Ψ (Ψ’ - i)
The function canWeaken is a different graph traversal than before: this time, we check irrel (un. D) A for the left-hand side of each computational arrow A ⊃ B. Weakening can then be defined using strengthening in contravariant positions, as irrel is exactly the condition that strengthening requires. This suffices for a simple version of weakening. However, we can be more clever, and observe that types of the form ∀⇒ A are always weakenable, because their proofs are explicitly parametrized over arbitrary extensions of the context. Similarly, ∀≤ C A is weakenable with any context composed entirely of C’s. Capitalizing on this observation requires a slight generalization of the traversal described above: computationally, weakening ∀⇒ A does not recursively traverse the proof of A, like map usually does, but stops the traversal and instantiates the context quantifier appropriately. Thus, our actual implementation of map is parametrized so that, for each type A, either it is given sufficient information to transform A directly (a function < Ψ > A -> < Ψ’ > A), or it has enough information to continue recursively, as in the compatibility conditions described above. We use the former only for weakening the quantifiers (map < Ψ - i > (∀⇒ A) to < Ψ > (∀⇒ A)). We refer the reader to our Agda code for details. All told, weakening takes about 210 lines of Agda code to define and prove compatible. 4.6
Related Work
6.
Substitution
Conclusion
In this paper, we have constructed a logical framework supporting datatypes that mix binding and computation: Our framework is implemented as a universe in the dependently typed programming language Agda. Binding is represented in a pronominal manner, so the type system can be used to reason about the scoping of variables. Our implementation provides datatype-generic implementations of
Substitution is similar to weakening and strengthening. Its invariant has the same form, using a condition canSubst (un. D) A. This condition ensures two things: (1) that D is irrelevant to the lefthand-sides of any computational arrow, so that substitution can be defined using weakening-with-irrelevant-assumptions in the contravariant position, and (2) that D is weakenable with all variable
132
e.g., we do not explicitly apply append to the Set argument A. Agda attempts to infer implicit function arguments and reports an error if they cannot be reconstructed. Indexed datatypes are defined using a notation similar to GADTs in GHC. For example, we define a datatype ∈ representing indices into a list:
the structural properties (weakening, subordination-based strengthening, exchange, contraction, and substitution). We have used the framework to program a number of examples, including a scopecorrect version of the normalization-by-evaluation challenge problem discussed by Shinwell et al. (2003). We believe that these examples demonstrate the viability of our approach for simply-typed programming. We hope also to have clarified the gap between LF-based systems for programming with binding, such as Twelf, Delphin, and Beluga, and a generic dependently typed programming language like Agda. For simply-typed programming, the benefits of the LFbased systems that we were unable to mimic include: (1) the ability to write pronominal variables with a named syntax; and (2) a convenient syntax for applying the structural properties. For example, the syntax of weakening and strengthening is relatively heavy in our setting. In Twelf, weakening is silent, and strengthening (including strengthen? used in the η-contraction example) is marked by saying which variables do occur, using a non-linear higher-order pattern. In our Agda implementation, weakening must be marked explicitly, and strengthening requires one to enumerate those variables that do not occur instead. However, the more convenient syntax seems within reach for a standalone implementation of our framework; e.g., weakening could be implemented using a form of coercive subtyping. Of course, one way in which all of the LF-based systems outpace ours is that they support dependent types, which are crucial for representing logics and for mechanizing metatheory. Our most pressing areas of future work are to investigate a dependently typed extension of our universe, and to address the termination issues that we have deferred here. One key issue for the dependently typed version will be the equational behavior of the structural properties, which we have not yet investigated. We would hope that they have the right behavior up to propositional equality (otherwise there is a bug in the code presented here), but it remains to be seen whether we can get Agda’s definitional equality to mimic the equations proved automatically by, e.g., Twelf. That said, the fact that the map function defined in Section 4 commutes with all term constructors definitionally in Agda gives us some hope in this regard.
data _∈_ {A : Set} : A -> List A -> Set where i0 : {x : A} {xs : List A} -> x ∈ (x :: xs) iS : {x y : A} {xs : List A} -> y ∈ xs -> y ∈ (x :: xs)
For any Set A, and terms x and xs of type A and List A, there is a type x ∈ xs. The first constructor, i0, creates a proof of x ∈ (x :: xs)—i.e. x is the first element of the list. The second constructor iS, creates a proof of x ∈ (y :: xs) from a proof that x is in the tail. As a simple example of dependent pattern matching, we define an n-ary version of iS: skip : {A : Set} (xs : List A) {ys : List A} {y : A} -> y ∈ ys -> y ∈ (append xs ys) skip [] i = i skip (x :: xs) i = iS (skip xs i)
We use an implicit-quantifier for all arguments but the list xs; explicit-quantifiers are written with parentheses instead of curlybraces. The fact that this code type-checks depends on the computational behavior of append; e.g., in the first case, the expression append [] ys reduces to ys, so we can return the index i unchanged. Well-scoped syntax for the untyped λ-calculus is defined as follows: data Term (Γ . : ∈ Lam : Term App : Term
: List Γ -> ( :: Γ ->
Unit) : Set where Term Γ Γ) -> Term Γ Term Γ -> Term Γ
In this section, we review Agda’s syntax, we show a simple example of well-scoped de Bruijn indices, and we give a simple example of a universe. We refer the reader to the Agda Wiki (http://wiki.portal.chalmers.se/agda/) for more introductory materials.
The type Unit is defined to be the record type with no fields, with inhabitant written . We represent variables as indices into a list Γ containing elements of the one-element type Unit. (Such lists are isomorphic to natural numbers, but this illustrates the pattern for variables of more than one type.) The constructor . makes a term from an index into Γ, which represents a variable. The body of Lam can refer to all of the variables in Γ, as well as a new bound variable represented by extending Γ to ( :: Γ). The K combinator λx.λy.x is represented as follows: Lam (Lam (. (iS i0))). The values of Term Γ correspond exactly to the λ-terms with free variables in Γ.
A.1
A.2
A.
Agda Overview
Well-scoped de Bruijn indices in Agda
Universes
A universe is specified by a inductive datatype of codes for types, along with a function mapping each code to a Set. For example, a simple universe with an empty type, a unit type, and binary products is specified as follows:
We review the representation of well-scoped de Bruijn indices as an indexed inductive definition (Altenkirch and Reus, 1999; Bellegarde and Hook, 1994; Bird and Paterson, 1999). Agda data types are introduced as follows:
data Type : Set where 0+ : Type 1+ : Type _⊗_ : Type -> Type -> Type
data List (A : Set) : Set where [] : List A _::_ : A -> List A -> List A
Set classifies Agda classifiers, like the kind type in ML or Haskell. Mixfix constructors are declared by using _ in an identifier; e.g., :: can now be used infix as in Zero :: (Zero :: []). Functions are defined by pattern-matching:
Element Element Element Element
append : {A : Set} -> List A -> List A -> List A append [] ys = ys append (x :: xs) ys = x :: (append xs ys)
: Type -> Set 0+ = Void 1+ = Unit (τ 1 ⊗ τ 2) = (Element τ 1) × (Element τ 2)
In the right-hand side of Element, we write A × B for the Agda pair type, etc. Datatype-generic programs are implemented by recursion over the codes; e.g, every element of the universe can be converted to a string:
The curly-braces mark an implicit dependent function space. Applications to implicit arguments are not marked in the program;
133
show show show show "<
A. Chlipala. A certified type-preserving compiler from λ-calculus to assembly language. In ACM SIGPLAN Conference on Programming Language Design and Implementation, 2007.
: (τ : Type) -> Element τ -> String 0+ () 1+ = "" (τ 1 ⊗ τ 2) (e1 , e2) = " ^ (show τ 1 e1) ^ " , " ^ (show τ 2 e2) ^ " >"
A. Chlipala. Parametric higher-order abstract syntax for mechanized semantics. In ACM SIGPLAN International Conference on Functional Programming. ACM, 2008.
In the first clause, the empty parentheses are a refutation pattern, telling Agda to check that the type in question (in this case Element 0+ ) is uninhabited, and allowing the programmer to elide the right-hand side. As another example, we will often view booleans as a twoelement universe, with only True inhabited:
K. Crary. Explicit contexts in LF. In International Workshop on Logical Frameworks and Meta-Languages: Theory and Practice, 2008. J. Despeyroux, A. Felty, and A. Hirschowitz. Higher-order abstract syntax in Coq. In M. Dezani-Ciancaglini and G. Plotkin, editors, International Conference on Typed Lambda Calculi and Applications, volume 902 of Lecture Notes in Computer Science, pages 124–138, Edinburgh, Scotland, 1995. Springer-Verlag.
data Bool : Set where True : Bool False : Bool
M. Fiore, G. Plotkin, and D. Turi. Abstract syntax and variable binding. In IEEE Symposium on Logic in Computer Science, 1999.
Check : Bool -> Set Check True = Unit Check False = Void
J. Hickey, A. Nogin, X. Yu, and A. Kopylov. Mechanized meta-reasoning using a hybrid HOAS/de Bruijn representation and reflection. In ACM SIGPLAN International Conference on Functional Programming, pages 172–183, New York, NY, USA, 2006. ACM.
Because Agda implements extensionality for Unit (there is only one record with no fields), terms of type Check True can be left implicit and inferred.
M. Hofmann. Semantical analysis of higher-order abstract syntax. In IEEE Symposium on Logic in Computer Science, 1999. D. R. Licata, N. Zeilberger, and R. Harper. Focusing on binding and computation. In IEEE Symposium on Logic in Computer Science, 2008.
Acknowledgements We thank Noam Zeilberger for discussions about this work, and we thank the anonymous reviewers for their helpful feedback on an earlier version of this article.
P. Martin-Löf. An intuitionistic theory of types: Predicative part. In H. Rose and J. Shepherdson, editors, Logic Colloquium. Elsevier, 1975. C. McBride and J. McKinna. The view from the left. Journal of Functional Programming, 15(1), 2004.
References
D. Miller and A. F. Tiu. A proof theory for generic judgments: An extended abstract. In IEEE Symposium on Logic in Computer Science, pages 118– 127, 2003.
M. Abbott, T. Altenkirch, and N. Ghani. Containers: constructing strictly positive types. Theoretic Computer Science, 342(1):3–27, 2005.
A. Momigliano, A. Martin, and A. Felty. Two-level hybrid: A system for reasoning using higher-order abstract syntax. In International Workshop on Logical Frameworks and Meta-Languages: Theory and Practice, 2007.
T. Altenkirch and C. McBride. Generic programming within dependently typed programming. In IFIP TC2 Working Conference on Generic Programming, Schloss Dagstuhl, 2003. T. Altenkirch and B. Reus. Monadic presentations of lambda terms using generalized inductive types. In CSL 1999: Computer Science Logic. LNCS, Springer-Verlag, 1999.
A. Nanevski, F. Pfenning, and B. Pientka. Contextual modal type theory. Transactions on Computational Logic, 2007. To appear. U. Norell. Towards a practical programming language based on dependent type theory. PhD thesis, Chalmers University of Technology, 2007.
S. Ambler, R. L. Crole, and A. Momigliano. Combining higher order abstract syntax with tactical theorem proving and (co)induction. In International Conference on Theorem Proving in Higher-Order Logics, pages 13–30, London, UK, 2002. Springer-Verlag.
F. Pfenning and C. Schürmann. System description: Twelf - a meta-logical framework for deductive systems. In H. Ganzinger, editor, International Conference on Automated Deduction, pages 202–206, 1999.
B. Aydemir, A. Charguéraud, B. C. Pierce, R. Pollack, and S. Weirich. Engineering formal metatheory. In ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages, pages 3–15, 2008.
B. Pientka. A type-theoretic foundation for programming with higher-order abstract syntax and first-class substitutions. In ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages, pages 371–382, 2008.
F. Bellegarde and J. Hook. Substitution: A formal methods case study using monads and transformations. Science of Computer Programming, 23(2– 3):287–311, 1994.
A. M. Pitts and M. J. Gabbay. A metalanguage for programming with bound names modulo renaming. In R. Backhouse and J. N. Oliveira, editors, Mathematics of Program Construction, volume 1837 of Lecture Notes in Computer Science, pages 230–255. Springer-Verlag, Heidelberg, 2000.
U. Berger and H. Schwichtenberg. An inverse of the evaluation functional for typed λ-calculus. In IEEE Symposium on Logic in Computer Science, 1991.
A. Poswolsky and C. Schürmann. Practical programming with higherorder encodings and dependent types. In European Symposium on Programming, 2008.
R. S. Bird and R. Paterson. De Bruijn notation as a nested datatype. Journal of Functional Programming, 9(1):77–91, 1999. A. Bucalo, M. Hofmann, F. Honsell, M. Miculan, and I. Scagnetto. Consistency of the theory of contexts. Journal of Functional Programming, 16 (3):327–395, May 2006.
F. Pottier. Static name control for FreshML. In IEEE Symposium on Logic in Computer Science, 2007. M. R. Shinwell, A. M. Pitts, and M. J. Gabbay. FreshML: Programming with binders made simple. In ACM SIGPLAN International Conference on Functional Programming, pages 263–274, August 2003.
V. Capretta and A. Felty. Combining de Bruijn indices and higher-order abstract syntax in Coq. In Proceedings of TYPES 2006, volume 4502 of Lecture Notes in Computer Science, pages 63–77. Springer-Verlag, 2007.
R. Virga. Higher-Order Rewriting with Dependent Types. PhD thesis, Carnegie Mellon University, 1999.
134
Non-Parametric Parametricity Georg Neis
Derek Dreyer
Andreas Rossberg
MPI-SWS
[email protected]
MPI-SWS
[email protected]
MPI-SWS
[email protected]
Abstract
type system, thus enabling the so-called type-erasure interpretation of polymorphism by which type abstractions and instantiations are erased during compilation. However, some modern programming languages include a useful feature that appears to be in direct conflict with parametric polymorphism, namely the ability to perform intensional type analysis [12]. Probably the simplest and most common instance of intensional type analysis is found in the implementation of languages supporting a type Dynamic [1]. In such languages, any value v may be cast to type Dynamic, but the cast from type Dynamic to any type τ requires a runtime check to ensure that v’s actual type equals τ . Other languages such as Acute [25] and Alice ML [23], which are designed to support dynamic loading of modules, require the ability to check dynamically whether a module implements an expected interface, which in turn involves runtime inspection of the module’s type components. There have also been a number of more experimental proposals for languages that employ a typecase construct to facilitate polytypic programming (e.g., [32, 29]). There is a fundamental tension between type analysis and type abstraction. If one can inspect the identity of an unknown type at run time, then the type is not really abstract, so any invariants concerning values of that type may be broken [32]. Consequently, languages with a type Dynamic often distinguish between castable and non-castable types—with types that mention user-defined abstract types belonging to the latter category—and prohibit values with non-castable types from being cast to type Dynamic. This is, however, an unnecessarily severe restriction, which effectively penalizes programmers for using type abstraction. Given a user-defined abstract type t—implemented internally, say, as int—it is perfectly reasonable to cast a value of type t → t to Dynamic, so long as we can ensure that it will subsequently be cast back only to t → t (not to, say, int → int or int → t), i.e., so long as the cast is abstraction-safe. Moreover, such casts are useful when marshalling (or “pickling”) a modular component whose interface refers to abstract types defined in other components [23]. That said, in order to ensure that casts are abstraction-safe, it is necessary to have some way of distinguishing (dynamically, when a cast occurs) between an abstract type and its implementation. Thus, several researchers have proposed that languages with type analysis facilities should also support dynamic type generation [24, 21, 29, 22]. The idea is simple: when one defines an abstract type, one should also be able to generate at run time a “fresh” type name, which may be used as a dynamic representative of the abstract type for purposes of type analysis.1 (We will see a concrete example of this in Section 2.) Intuitively, the freshness of type name generation ensures that user-defined abstract types are viewed dynamically in the same way that they are viewed statically—i.e., as distinct from all other types.
Type abstraction and intensional type analysis are features seemingly at odds—type abstraction is intended to guarantee parametricity and representation independence, while type analysis is inherently non-parametric. Recently, however, several researchers have proposed and implemented “dynamic type generation” as a way to reconcile these features. The idea is that, when one defines an abstract type, one should also be able to generate at run time a fresh type name, which may be used as a dynamic representative of the abstract type for purposes of type analysis. The question remains: in a language with non-parametric polymorphism, does dynamic type generation provide us with the same kinds of abstraction guarantees that we get from parametric polymorphism? Our goal is to provide a rigorous answer to this question. We define a step-indexed Kripke logical relation for a language with both non-parametric polymorphism (in the form of type-safe cast) and dynamic type generation. Our logical relation enables us to establish parametricity and representation independence results, even in a non-parametric setting, by attaching arbitrary relational interpretations to dynamically-generated type names. In addition, we explore how programs that are provably equivalent in a more traditional parametric logical relation may be “wrapped” systematically to produce terms that are related by our non-parametric relation, and vice versa. This leads us to a novel “polarized” form of our logical relation, which enables us to distinguish formally between positive and negative notions of parametricity. Categories and Subject Descriptors D.3.3 [Programming Languages]: Language Constructs and Features—Abstract data types; F.3.1 [Logics and Meanings of Programs]: Specifying and Verifying and Reasoning about Programs General Terms
Languages, Theory, Verification
Keywords Parametricity, intensional type analysis, representation independence, step-indexed logical relations, type-safe cast
1. Introduction When we say that a language supports parametric polymorphism, we mean that “abstract” types in that language are really abstract— that is, no client of an abstract type can guess or depend on its underlying implementation [20]. Traditionally, the parametric nature of polymorphism is guaranteed statically by the language’s
Permission to make digital or hard copies of all or part of this work for personal or classroom use is granted without fee provided that copies are not made or distributed for profit or commercial advantage and that copies bear this notice and the full citation on the first page. To copy otherwise, to republish, to post on servers or to redistribute to lists, requires prior specific permission and/or a fee. ICFP’09, August 31–September 2, 2009, Edinburgh, Scotland, UK. c 2009 ACM 978-1-60558-332-7/09/08. . . $5.00 Copyright
1 In
languages with simple module mechanisms, such as Haskell, it is possible to generate unique type names statically. However, this is not sufficient in the presence of functors and local or first-class modules.
135
The question remains: how do we know that dynamic type generation works? In a language with intensional type analysis— i.e., non-parametric polymorphism—can the systematic use of dynamic type generation provably ensure abstraction safety and provide us with the same kinds of abstraction guarantees that we get from traditional parametric polymorphism? Our goal is to provide a rigorous answer to this question. We study an extension of System F, supporting (1) a type-safe cast mechanism, which is essentially a variant of Girard’s J operator [9], and (2) a facility for dynamic generation of fresh type names. For brevity, we will call this language G. As a practical language mechanism, the cast operator is somewhat crude in comparison to the more expressive typecase-style constructs proposed in the literature,2 but it nonetheless renders polymorphism non-parametric. Our main technical result is that, in a language with non-parametric polymorphism, parametricity may be provably regained via judicious use of dynamic type generation. The rest of the paper is structured as follows. In Section 2, we present our language under consideration, G, and also give an example to illustrate how dynamic type generation is useful. In Section 3, we explain informally the approach that we have developed for reasoning about G. Our approach employs a stepindexed Kripke logical relation, with an unusual form of possible world that is a close relative of Sumii and Pierce’s [26]. This section is intended to be broadly accessible to readers who are generally familiar with the basic idea of relational parametricity but not with the details of (advanced) logical relations techniques. In Section 4, we formalize our logical relation for G and show how it may be used to reason about parametricity and representation independence. A particularly appealing feature of our formalization is that the non-parametricity of G is encapsulated in the notion of what it means for two types to be logically related to each other when viewed as data. The definition of this type-level logical relation is a one-liner, which can easily be replaced with an alternative “parametric” version. In Sections 5–8, we explore how terms related by the parametric version of our logical relation may be “wrapped” systematically to produce terms related by the non-parametric version (and vice versa), thus clarifying how dynamic type generation facilitates parametric reasoning. This leads us to a novel “polarized” form of our logical relation, which enables us to distinguish formally between positive and negative notions of parametricity. In Section 9, we extend G with iso-recursive types to form Gμ and adapt the previous development accordingly. Then, in Section 10, we discuss how the abovementioned “wrapping” function can be seen as an embedding of System F (+ recursive types) into Gμ , which we conjecture to be fully abstract. Finally, in Section 11, we discuss related work, including recent work on the relevant concepts of dynamic sealing [27] and multilanguage interoperation [13], and in Section 12, we conclude and suggest directions for future work.
τ ::= α | b | τ → τ | τ × τ | ∀α.τ | ∃α.τ v ::= x | c | λx:τ.e | v1 , v2 | λα.e | pack τ, v as τ e ::= v | e e | e1 , e2 | e.1 | e.2 | e τ | pack τ, e as τ | unpack α, x=e in e | cast τ τ | new α≈τ in e Stores σ ::= | σ, α≈τ Config’s ζ ::= σ; e
Types Values Terms
Type Contexts Δ ::= | Δ, α | Δ, α≈τ Value Contexts Γ ::= | Γ, x:τ Δ; Γ e : τ
···
(E CAST) (E NEW)
Δ τ2 Δ τ1 Δ; Γ cast τ1 τ2 : τ1 → τ2 → τ2
Δτ
(E CONV)
Δ, α≈τ ; Γ e : τ Δ τ Δ; Γ new α≈τ in e : τ Δ; Γ e : τ Δ τ ≈ τ Δ; Γ e : τ
Δτ α≈τ ∈ Δ Δα
···
α≈τ ∈ Δ Δα≈τ
···
(T NAME) Δτ ≈τ (C NAME) ζ :τ (CONF)
σ
σ; e : τ σ; e : τ
σ; (λx:τ.e) v σ; v1 , v2 .i σ; (λα.e) τ σ; unpack α, x=(pack τ, v) in e (α ∈ / dom(σ)) σ; new α≈τ in e σ; cast τ1 τ2 (τ1 = τ2 ) (τ1 = τ2 ) σ; cast τ1 τ2
→ → → → → → →
τ
σ; e[v/x] σ; vi σ; e[τ /α] σ; e[τ /α][v/x] σ, α≈τ ; e σ; λx1 :τ1 .λx2 :τ2 .x1 σ; λx1 :τ1 .λx2 :τ2 .x2
(. . . plus standard “search” rules . . . ) Figure 1. Syntax and Semantics of G (excerpt) • cast τ1 τ2 v1 v2 converts v1 from type τ1 to τ2 . It checks that
those two types are the same at the time of evaluation. If so, the operator succeeds and returns v1 . Otherwise, it fails and defaults to v2 , which acts as an else clause of the target type τ2 . • new α≈τ in e generates a fresh abstract type name α. Values
2. The Language G
of type α can be formed using its representation type τ . Both types are deemed compatible, but not equivalent. That is, they are considered equal as classifiers, but not as data. In particular, cast α τ v v will not succeed (i.e., it will return v ).
Figure 1 defines our non-parametric language G. For the most part, G is a standard call-by-value λ-calculus, consisting of the usual types and terms from System F [9], including pairs and existential types.3 We also assume an unspecified set of base types b, along with suitable constants c of those types. Two additional, non-standard constructs isolate the essential aspects of the class of languages we are interested in:
Our cast operator is essentially the same as Harper and Mitchell’s TypeCond operator [11], which was itself a variant of the nonparametric J operator that Girard studied in his thesis [9]. Our new construct is similar to previously proposed constructs for dynamic type generation [21, 29, 22]. However, we do not require explicit term-level type coercions to witness the isomorphism between an abstract type name α and its representation τ . Instead, our type system is simple enough that we perform this conversion implicitly.
2 That
said, the implementation of dynamic modules in Alice ML, for instance, employs a very similar construct [23]. 3 We could use a Church encoding of existentials through universals, but distinguishing them gives us more leeway later (cf. Section 5).
136
For convenience, we will occasionally use expressions of the form let x=e1 in e2 , which abbreviate the term (λx:τ1 .e2 ) e1 (with τ1 being an appropriate type for e1 ). We omit the type annotation for existential packages where clear from context. Moreover, we take the liberty to generalize binary tuples to n-ary ones where necessary and to use pattern matching notation to decompose tuples in the obvious manner.
Type preservation can be expressed using the typing rule CONF for configurations. We formulate this rule by treating the type store as a type context, which is possible because type stores are a syntactic subclass of type contexts. (In a similar manner, we can write σ for well-formedness of store σ, by viewing it as a type context.) It is worth noting that the representation types in the store are actually never inspected by the dynamic semantics. They are only needed for specifying well-formedness of configurations and proving type soundness.
2.1 Typing Rules The typing rules for the System F fragment of G are completely standard and thus omitted from Figure 1. We focus on the nonstandard rules related to cast and new. Full formal details of the type system appear in the expanded version of this paper [16]. Typing of casts is straightforward (Rule E CAST): cast τ1 τ2 is simply treated as a function of type τ1 → τ2 → τ2 . Its first argument is the value to be converted, and its second argument is the default value returned in the case of failure. The rule merely requires that the two types be well-formed. For an expression new α≈τ in e, which binds α in e, Rule E NEW checks that the body e is well-typed under the assumption that α is implemented by the representation type τ . For that purpose, we enrich type contexts Δ with entries of the form α≈τ that keep track of the representation types tied to abstract type names. Note that τ may not mention α. Syntactically, type names are just type variables. When viewed as data, (i.e., when inspected by the cast operator), types are considered equivalent iff they are syntactically equal. In contrast, when viewed as classifiers for terms, knowledge about the representation of type names may be taken into account. Rule E CONV says that if a term e has a type τ , it may be assigned any other type that is compatible with τ . Type compatibility, in turn, is defined by the judgment Δ τ1 ≈ τ2 . We only show the rule C NAME, which discharges a compatibility assumption α≈τ from the context; the other rules implement the congruence closure of this axiom. The important point here is that equivalent types are compatible, but compatible types are not necessarily equivalent. Finally, Rule E NEW also requires that the type τ of the body e does not contain α (i.e., τ must be well formed in Δ alone). A type of this form can always be derived by applying E CONV to convert τ to τ [τ /α].
2.3 Motivating Example Consider the following attempt to write a simple functional “binary semaphore” ADT [17] in G. Following Mitchell and Plotkin [15], we use an existential type, as we would in System F: τsem := ∃α.α × (α → α) × (α → bool) esem := pack int, 1, λx: int .(1 − x), λx: int .(x = 0) as τsem A semaphore essentially is a flag that can be in two states: either locked or unlocked. The state can be toggled using the first function of the ADT, and it can be polled using the second. Our little module uses an integer value for representing the state, taking 1 for locked and 0 for unlocked. It is an invariant of the implementation that the integer never takes any other value—otherwise, the toggle function would no longer operate correctly. In System F, the implementation invariant would be protected by the fact that existential types are parametric: there is no way to inspect the witness of α after opening the package, and hence no client could produce values of type α other than those returned by the module (nor could she apply integer operations to them). Not so in G. The following program uses cast to forge a value s of the abstract semaphore type α: eclient := unpack α, s0 , toggle, poll = esem in let s = cast int α 666 s0 in poll s, poll (toggle s) Because reduction of unpack simply substitutes the representation type int for α, the consecutive cast succeeds, and the whole expression evaluates to true, true—although the second component should have toggled s and thus be different from the first. The way to prevent this in G is to create a fresh type name as witness of the abstract type:
2.2 Dynamic Semantics
esem1 := new α ≈ int in pack α , 1, λx: int .(1 − x), λx: int .(x = 0) as τsem
The operational semantics has to deal with generation of fresh type names. To that end, we introduce a type store σ to record generated type names. Hence, reduction is defined on configurations (σ; e) instead of plain terms. Figure 1 shows the main reduction rules. We omit the standard “search” rules for descending into subterms according to call-by-value, left-to-right evaluation order. The reduction rules for the F fragment are as usual and do not actually touch the store. However, types occurring in F constructs can contain type names bound in the store. Reducing the expression new α≈τ in e creates a new entry for α in the type store. We rely on the usual hygiene convention for bound variables to ensure that α is fresh with respect to the current store (which can always be achieved by α-renaming).4 The two remaining rules are for casts. A cast takes two types and checks that they are equivalent (i.e., syntactically equal). In either case, the expression reduces to a function that will return the appropriate one of the additional value arguments, i.e., the value to be converted in case of success, and the default value otherwise. In the former case, type preservation is ensured because source and target types are known to be equivalent.
After replacing the initial semaphore implementation with this one, eclient will evaluate to true, false as desired—the cast expression will no longer succeed, because α will be substituted by the dynamic type name α , and α = int. (Moreover, since α is only visible statically in the scope of the new expression, the client has no access to α , and thus cannot convert from int to α either.) Now, while it is clear that new ensures proper type abstraction in the client program eclient , we want to prove that it does so for any client program. A standard way of doing so is by showing a more general property, namely representation independence [20]: we show that the module esem1 is contextually equivalent to another module of the same type, meaning that no G program can observe any difference between the two modules. By choosing that other module to be a suitable reference implementation of the ADT in question, we can conclude that the “real” one behaves properly under all circumstances. The obvious candidate for a reference implementation of the semaphore ADT is the following: esem2 := new α ≈ bool in pack α , true, λx: bool .¬x, λx: bool .x as τsem
4A
well-known alternative approach would omit the type store in favor of using scope extrusion rules for new binders, as in Rossberg [21].
137
say that two functions are logically related at the type τ1 → τ2 if, when passed arguments that are logically related at τ1 , either they both diverge or they both converge to values that are logically related at τ2 . The fundamental theorem of logical relations states that the logical relation is a congruence with respect to the constructs of the language. Together with what Pitts [17] calls adequacy—i.e., the fact that logically related terms have equivalent termination behavior—the fundamental theorem implies that logically related terms are contextually equivalent, since contextual equivalence is defined precisely to be the largest adequate congruence. Traditionally, the parametric nature of polymorphism is made clear by the definition of the logical relation for universal and existential types. Intuitively, two type abstractions, λα.e1 and λα.e2 , are logically related at type ∀α.τ if they map related type arguments to related results. But what does it mean for two type arguments to be related? Moreover, once we settle on two related type arguments τ1 and τ2 , at what type do we relate the results e1 [τ1 /α] and e2 [τ2 /α]? One approach would be to restrict “related type arguments” to be the same type τ . Thus, λα.e1 and λα.e2 would be logically related at ∀α.τ iff, for any (closed) type τ , it is the case that e1 [τ /α] and e2 [τ /α] are logically related at the type τ [τ /α]. A key problem with this definition, however, is that, due to the quantification over any argument type τ , the type τ [τ /α] may in fact be larger than the type ∀α.τ , and thus the definition of the logical relation is no longer inductive in the structure of the type. Another problem is that this definition does not tell us anything about the parametric nature of polymorphism. Reynolds’ alternative approach is a generalization of Girard’s “candidates” method for proving strong normalization for System F [9]. The idea is simple: instead of defining two type arguments to be related only if they are the same, allow any two different type arguments to be related by an (almost) arbitrary relational interpretation (subject to certain admissibility constraints). That is, we parameterize the logical relation at type τ by an interpretation function ρ, which maps each free type variable of τ to a pair of types τ1 , τ2 together with some (admissible) relation between values of those types. Then, we say that λα.e1 and λα.e2 are logically related at type ∀α.τ under interpretation ρ iff, for any closed types τ1 and τ2 and any relation R between values of those types, it is the case that e1 [τ1 /α] and e2 [τ2 /α] are logically related at type τ under interpretation ρ, α → (τ1 , τ2 , R). The miracle of Reynolds/Girard’s method is that it simultaneously (1) renders the logical relation inductively well-defined in the structure of the type, and (2) demonstrates the parametricity of polymorphism: logically related type abstractions must behave the same even when passed completely different type arguments, so their behavior may not analyze the type argument and behave in different ways for different arguments. Dually, we can show that two ADTs pack τ1 , v1 as ∃α.τ and pack τ2 , v2 as ∃α.τ are logically related (and thus contextually equivalent) by exhibiting some relational interpretation R for the abstract type α, even if the underlying type representations τ1 and τ2 are different. This is the essence of what is meant by “representation independence”. Unfortunately, in the setting of G, Reynolds/Girard’s method is not directly applicable, precisely because polymorphism in G is not parametric! This essentially forces us back to the first approach suggested above, namely to only consider type arguments to be logically related if they are equal. Moreover, it makes sense: the cast operator views types as data, so types may only be logically related if they are indistinguishable as data. The natural questions, then, are: (1) what metric do we use to define the logical relation inductively, since the structure of the type no longer suffices, and (2) how do we establish that dynamic
Here, the semaphore state is represented directly by a Boolean flag and does not rely on any additional invariant. If we can show that esem1 is contextually equivalent to esem2 , then we can conclude that esem1 ’s type representation is truly being held abstract. 2.4 Contextual Equivalence In order to be able to reason about representation independence, we need to make precise the notion of contextual equivalence. A context C is an expression with a single hole [ ], defined in the usual manner. Typing of contexts is defined by a judgment form C : (Δ; Γ; τ ) (Δ ; Γ ; τ ), where the triple (Δ; Γ; τ ) indicates the type of the hole. The judgment implies that for any expression e with Δ; Γ e : τ we have Δ ; Γ C[e] : τ . The rules are straightforward, the key rule being the one for holes: Γ ⊆ Γ Δ ⊆ Δ [ ] : (Δ; Γ; τ ) (Δ ; Γ ; τ ) We can now define contextual approximation and contextual equivalence as follows (with σ; e ↓ asserting that σ; e terminates): Definition 2.1 (Contextual Approximation and Equivalence) Let Δ; Γ e1 : τ and Δ; Γ e2 : τ . Δ; Γ e1 e2 : τ ⇔ ∀C, τ , σ. σ ∧ C : (Δ; Γ; τ ) (σ; ; τ ) ∧ σ; C[e1 ] ↓ =⇒ σ; C[e2 ] ↓ def Δ; Γ e1 e2 : τ ⇔ Δ; Γ e1 e2 : τ ∧ Δ; Γ e2 e1 : τ def
That is, contextual approximation Δ; Γ e1 e2 : τ means that for any well-typed program context C with a hole of appropriate type, the termination of C[e1 ] implies the termination of C[e2 ]. Contextual equivalence Δ; Γ e1 e2 : τ is just approximation in both directions. Considering that G does not explicitly contain any recursive or looping constructs, the reader may wonder why termination is used as the notion of “distinguishing observation” in our definition of contextual equivalence. The reason is that the cast operator, together with impredicative polymorphism, makes it possible to write well-typed non-terminating programs [11]. (This was Girard’s reason for studying the J operator in the first place [9].) Moreover, using cast, one can encode arbitrary recursive function definitions. Other forms of observation may then be encoded in terms of (non-)termination. See the expanded version of this paper for details [16].
3. A Logical Relation for G: Main Ideas Following Reynolds [20] and Mitchell [14], our general approach to reasoning about parametricity and representation independence is to define a logical relation. Essentially, logical relations give us a tractable way of proving that two terms are contextually equivalent, which in turn gives us a way of proving that abstract types are really abstract. Of course, since polymorphism in G is non-parametric, the definition of our logical relation in the cases of universal and existential types is somewhat unusual. To place our approach in context, we first review the traditional approach to defining logical relations for languages with parametric polymorphism, such as System F. 3.1 Logical Relations for Parametric Polymorphism Although the technical meaning of “logical relation” is rather woolly, the basic idea is to define an equivalence (or approximation) relation on programs inductively, following the structure of their types. To take the canonical example of arrow types, we would
138
type generation regains a form of parametricity? We address these questions in the next two sections, respectively.
We now must show that the values pack α , 1, λx: int .(1 − x), λx: int .(x = 0) as τsem
3.2 Step-Indexed Logical Relations for Non-Parametricity
and pack α , true, λx: bool .¬x, λx: bool .x as τsem
First, in order to provide a metric for inductively defining the logical relation, we employ step-indexing. Step-indexed logical relations were proposed originally by Appel and McAllester [7] as a way of giving a simple operational-semantics-based model for general recursive types in the context of foundational proofcarrying code. In subsequent work by Ahmed and others [3, 6], the method has been adapted to support relational reasoning in a variety of settings, including untyped and imperative languages. The key idea of step-indexed logical relations is to index the definition of the logical relation not only by the type of the programs being related, but also by a natural number n representing (intuitively) “the number of steps left in the computation”. That is, if two terms e1 and e2 are logically related at type τ for n steps, then if we place them in any program context C and run the resulting programs for n steps of computation, we should not be able to produce observably different results (e.g., C[e1 ] evaluating to 5 and C[e2 ] evaluating to 7). To show that e1 and e2 are contextually equivalent, then, it suffices to show that they are logically related for n steps, for any n. To see how step-indexing helps us, consider how we might define a step-indexed logical relation for G in the case of universal types: two type abstractions λα.e1 and λα.e2 are logically related at ∀α.τ for n steps iff, for any type argument τ , it is the case that e1 [τ /α] and e2 [τ /α] are logically related at τ [τ /α] for n − 1 steps. This reasoning is sound because the only way a program context can distinguish between λα.e1 and λα.e2 in n steps is by first applying them to a type argument τ —which incurs a step of computation for the β-reduction (λα.ei ) τ → ei [τ /α]—and then distinguishing between e1 [τ /α] and e2 [τ /α] within the next n − 1 steps. Moreover, although the type τ [τ /α] may be larger than ∀α.τ , the step index n − 1 is smaller, so the logical relation is inductively well-defined.
are logically related in the world w. Since G’s logical relation for existential types is non-parametric, the two packages must have the same type representation, but of course the whole point of using new was to ensure that they do (namely, it is α ). The remainder of the proof is showing that the value components of the packages are related at the type α × (α → α ) × (α → bool) under the interpretation ρ = α → (int, bool, R) derived from the world w. This last part is completely analogous to what one would show in a standard representation independence proof. In short, the possible worlds in our Kripke logical relations bring back the ability to assign arbitrary relational interpretations R to abstract types, an ability that was seemingly lost when we moved to a non-parametric logical relation. The only catch is that we can only assign arbitrary interpretations to dynamic type names, not to static, universally/existentially quantified type variables. There is one minor technical matter that we glossed over in the above proof sketch but is worth mentioning. Due to nondeterminism of type name allocation, the evaluation of esem1 and esem2 may result in α being replaced by α1 in the former and α2 in the latter (for some fresh α1 = α2 ). Moreover, we are also interested in proving equivalence of programs that do not necessarily allocate exactly the same number of type names in the same order. Consequently, we also include in our possible worlds a partial bijection η between the type names of the first program and the type names of the second program, which specifies how each dynamically generated abstract type is concretely represented in the stores of the two programs. We require them to be in 1-1 correspondence because the cast construct permits the program context to observe equality on type names, as follows: def
equal? : ∀α.∀β. bool = Λα.Λβ. cast ((α → α) → bool) ((β → β) → bool) (λx:(α → α). true)(λx:(β → β). false)(λx:β.x)
3.3 Kripke Logical Relations for Dynamic Parametricity Second, in order to establish the parametricity properties of dynamic type generation, we employ Kripke logical relations, i.e., logical relations that are indexed by possible worlds.5 Kripke logical relations are appropriate when reasoning about properties that are true only under certain conditions, such as equivalence of modules with local mutable state. For instance, an imperative ADT might only behave according to its specification if its local data structures obey certain invariants. Possible worlds allow one to codify such local invariants on the machine store [18]. In our setting, the local invariant we want to establish is what a dynamically generated type name means. That is, we will use possible worlds to assign relational interpretations to dynamically generated type names. For example, consider the programs esem1 and esem2 from Section 2. We want to show they are logically related at ∃α. α × (α → α) × (α → bool) in an empty initial world w0 (i.e., under empty type stores). The proof proceeds roughly as follows. First, we evaluate the two programs. This will have the effect of generating a fresh type name α , with α ≈ int extending the type store of the first program and α ≈ bool extending the type store of the second program. At this point, we correspondingly extend the initial world w0 with a mapping from α to the relation R = {(1, true), (0, false)}, thus forming a new world w that specifies the semantic meaning of α .
We then consider types to be logically related if they are the same up to this bijection. For instance, in our running example, when extending w0 to w, we would not only extend its relational interpretation with α → (int, bool, R) but also extend its η with α → (α1 , α2 ). Thus, the type representations of the two existential packages, α1 and α2 , though syntactically distinct, would still be logically related under w.
4. A Logical Relation for G: Formal Details Figure 2 displays our step-indexed Kripke logical relation for G in full gory detail. It is easiest to understand this definition by making two passes over it. First, as the step indices have a way of infecting the whole definition in a superficially complex—but really very straightforward—way, we will first walk through the whole definition ignoring all occurrences of n’s and k’s (as well as auxiliary functions like the ·n operator). Second, we will pinpoint the few places where step indices actually play an important role in ensuring that the logical relation is inductively well-founded. 4.1 Highlights of the Logical Relation The first section of Figure 2 defines the kinds of semantic objects that are used in the construction of the logical relation. Relations R are sets of atoms, which are pairs of terms, e1 and e2 , indexed by a possible world w. The definition of Atom[τ1 , τ2 ] requires that e1 and e2 have the types τ1 and τ2 under the type stores w.σ1 and w.σ2 , respectively. (We use the dot notation w.σi to denote the i-th
5 In fact, step-indexed logical relations may already be understood as a special case of Kripke logical relations, in which the step index serves as the notion of possible world, and where n is a future world of m iff n ≤ m.
139
Atomn [τ1 , τ2 ] Reln [τ1 , τ2 ] SomeReln Interpn Conc Worldn (σ1 , σ2 , η, ρ)n ρn (τ1 , τ2 , R)n Rn R
Vn [[α]]ρ Vn [[b]]ρ Vn [[τ × τ ]]ρ
def
Vn [[τ → τ ]]ρ
def
= = def = def
=
def
Vn [[∀α.τ ]]ρ
=
def
Vn [[∃α.τ ]]ρ
=
def
En [[τ ]]ρ
=
def
= = def = def = def = def = def
def
{(k, w, e1 , e2 ) | k < n ∧ w ∈ Worldk ∧ w.σ1 ; e1 : τ1 ∧ w.σ2 ; e2 : τ2 } {R ⊆ Atomval n [τ1 , τ2 ] | ∀(k, w, v1 , v2 ) ∈ R. ∀(k , w ) (k, w). (k , w , v1 , v2 ) ∈ R} {r = (τ1 , τ2 , R) | fv(τ1 , τ2 ) = ∅ ∧ R ∈ Reln [τ1 , τ2 ]} fin {ρ ∈ TVar → SomeReln } fin {η ∈ TVar → TVar × TVar | ∀α, α ∈ dom(η). α = α ⇒ η 1 (α) = η 1 (α ) ∧ η 2 (α) = η 2 (α )} {w = (σ1 , σ2 , η, ρ) | σ1 ∧ σ2 ∧ η ∈ Conc ∧ ρ ∈ Interpn ∧ dom(η) = dom(ρ) ∧ ∀α ∈ dom(ρ). σ1 ρ1 (α) ≈ η 1 (α) ∧ σ2 ρ2 (α) ≈ η 2 (α)}
= = def = def =
(σ1 , σ2 , η, ρn ) {α→rn | ρ(α) = r} (τ1 , τ2 , Rn ) {(k, w, e1 , e2 ) ∈ R | k < n}
def
{(k, w, e1 , e2 ) | k = 0 ∨ (k − 1, wk−1 , e1 , e2 ) ∈ R}
def
=
(k , w ) (k, w)
⇔
η η ρ ρ
⇔ def ⇔
def
def
k ≤ k ∧ w ∈ Worldk ∧ w .η w.η ∧ w .ρ w.ρk ∧ ∀i ∈ {1, 2}. w .σi ⊇ w.σi ∧ rng(w .η i ) − rng(w.η i ) ⊆ dom(w .σi ) − dom(w.σi ) ∀α ∈ dom(η). η (α) = η(α) ∀α ∈ dom(ρ). ρ (α) = ρ(α)
ρ(α).Rn {(k, w, c, c) ∈ Atomn [b, b]} {(k, w, v1 , v1 , v2 , v2 ) ∈ Atomn [ρ1 (τ × τ ), ρ2 (τ × τ )] | (k, w, v1 , v2 ) ∈ Vn [[τ ]]ρ ∧ (k, w, v1 , v2 ) ∈ Vn [[τ ]]ρ} {(k, w, λx:τ1 .e1 , λx:τ2 .e2 ) ∈ Atomn [ρ1 (τ → τ ), ρ2 (τ → τ )] | ∀(k , w , v1 , v2 ) ∈ Vn [[τ ]]ρ. (k , w ) (k, w) ⇒ (k , w , e1 [v1 /x], e2 [v2 /x]) ∈ En [[τ ]]ρ} {(k, w, λα.e1 , λα.e2 ) ∈ Atomn [ρ1 (∀α.τ ), ρ2 (∀α.τ )] | ∀(k , w ) (k, w). ∀(τ1 , τ2 , r) ∈ Tk [[Ω]]w . (k , w , e1 [τ1 /α], e2 [τ2 /α]) ∈ En [[τ ]]ρ, α→r} {(k, w, pack τ1 , v1 , pack τ2 , v2 ) ∈ Atomn [ρ1 (∃α.τ ), ρ2 (∃α.τ )] | ∃r. (τ1 , τ2 , r) ∈ Tk [[Ω]]w ∧ (k, w, v1 , v2 ) ∈ Vn [[τ ]]ρ, α→r} {(k, w, e1 , e2 ) ∈ Atomn [ρ1 (τ ), ρ2 (τ )] | ∀j < k. ∀σ1 , v1 . (w.σ1 ; e1 →j σ1 ; v1 ) ⇒ ∃w , v2 . (k − j, w ) (k, w) ∧ w .σ1 = σ1 ∧ (w.σ2 ; e2 →∗ w .σ2 ; v2 ) ∧ (k − j, w , v1 , v2 ) ∈ Vn [[τ ]]ρ}
def
{(w.η 1 (τ ), w.η 2 (τ ), (w.ρ1 (τ ), w.ρ2 (τ ), Vn [[τ ]]w.ρ)) | fv(τ ) ⊆ dom(w.ρ)}
Gn [[]]ρ Gn [[Γ, x:τ ]]ρ
def
Dn [[]]w Dn [[Δ, α]]w
def
Dn [[Δ, α≈τ ]]w
def
{(k, w, ∅, ∅) | k < n ∧ w ∈ Worldk } {(k, w, (γ1 , x→v1 ), (γ2 , x→v2 )) | (k, w, γ1 , γ2 ) ∈ Gn [[Γ]]ρ ∧ (k, w, v1 , v2 ) ∈ Vn [[τ ]]ρ} {(∅, ∅, ∅)} {((δ1 , α→τ1 ), (δ2 , α→τ2 ), (ρ, α→r)) | (δ1 , δ2 , ρ) ∈ Dn [[Δ]]w ∧ (τ1 , τ2 , r) ∈ Tn [[Ω]]w} {((δ1 , α→β1 ), (δ2 , α→β2 ), (ρ, α→r)) | (δ1 , δ2 , ρ) ∈ Dn [[Δ]]w ∧ ∃α . w.ρ(α ) = r ∧ w.η(α ) = (β1 , β2 ) ∧ w.σ1 (β1 ) = δ1 (τ ) ∧ w.σ2 (β2 ) = δ2 (τ ) ∧ r.R = Vn [[τ ]]ρ}
Tn [[Ω]]w
= = =
def
= =
def
=
def
Δ; Γ e1 e2 : τ ⇔
Δ; Γ e1 : τ ∧ Δ; Γ e2 : τ ∧ ∀n ≥ 0. ∀w0 ∈ Worldn . ∀(δ1 , δ2 , ρ) ∈ Dn [[Δ]]w0 . ∀(k, w, γ1 , γ2 ) ∈ Gn [[Γ]]ρ. (k, w) (n, w0 ) ⇒ (k, w, δ1 γ1 (e1 ), δ2 γ2 (e2 )) ∈ En [[τ ]]ρ Figure 2. Logical Relation for G
type store component of w, and analogous notation for projecting out the other components of worlds.) Rel[τ1 , τ2 ] defines the set of admissible relations, which are permitted to be used as the semantic interpretations of abstract types. For our purposes, admissibility is simply monotonicity—i.e., closure under world extension. That is, if a relation in Rel relates
two values v1 and v2 under a world w, then the relation must relate those values in any future world of w. (We discuss the definition of world extension below.) Monotonicity is needed in order to ensure that we can extend worlds with interpretations of new dynamic type names, without interfering somehow with the interpretations of the old ones.
140
Worlds w are 4-tuples (σ1 , σ2 , η, ρ), which describe a set of assumptions under which pairs of terms are related. Here, σ1 and σ2 are the type stores under which the terms are typechecked and evaluated. The finite mappings η and ρ share a common domain, which can be understood as the set of abstract type names that have been generated dynamically. These “semantic” type names do not exist in either store σ1 or σ2 .6 Rather, they provide a way of referring to an abstract type that is represented by some type name α1 in σ1 and some type name α2 in σ2 . Thus, for each name α ∈ dom(η) = dom(ρ), the concretization η maps the “semantic” name α to a pair of “concrete” names from the stores σ1 and σ2 , respectively. (See the end of Section 3.3 for an example of such an η.) As the definition of Conc makes clear, distinct semantic type names must have distinct concretizations; consequently, η represents a partial bijection between σ1 and σ2 . The last component of the world w is ρ, which assigns relational interpretations to the aforementioned semantic type names. Formally, ρ maps each α to a triple r = (τ1 , τ2 , R), where R is a monotone relation between values of types τ1 and τ2 . (Again, see the end of Section 3.3 for an example of such a ρ.) The final condition in the definition of World stipulates that the closed syntactic types in the range of ρ and the concrete type names in the range of η are compatible. As a matter of notation, we will write η i and ρi to denote the type substitutions {α → αi | η(α) = (α1 , α2 )} and {α → τi | ρ(α) = (τ1 , τ2 , R)}, respectively. The second section of Figure 2 displays the definition of world extension. In order for w to extend w (written w w), it must be the case that (1) w specifies semantic interpretations for a superset of the type names that w interprets, (2) for the names that w interprets, w must interpret them in the same way, and (3) any new semantic type names that w interprets may only correspond to new concrete type names that did not exist in the stores of w. Although the third condition is not strictly necessary, we have found it to be useful when proving certain examples (e.g., the “order independence” example in Section 4.4). The last section of Figure 2 defines the logical relation itself. V [[τ ]]ρ is the logical relation for values, E[[τ ]]ρ is the one for terms, and T [[Ω]]w is the one for types as data, as described in Section 3 (here, Ω represents the kind of types). V [[τ ]]ρ relates values at the type τ , where the free type variables of τ are given relational interpretations by ρ. Ignoring the step indices, V [[τ ]]ρ is mostly very standard. For instance, at certain points (namely, in the → and ∀ cases), when we quantify over logically related (value or type) arguments, we must allow them to come from an arbitrary future world w in order to ensure monotonicity. This kind of quantification over future worlds is commonplace in Kripke logical relations. The only really interesting bit in the definition of V [[τ ]]ρ is the use of T [[Ω]]w to characterize when the two type arguments (resp. components) of a universal (resp. existential) are logically related. As explained in Section 3.3, we consider two types to be logically related in world w iff they are the same up to the partial bijection w.η. Formally, we define T [[Ω]]w as a relation on triples (τ1 , τ2 , r), where τ1 and τ2 are the two logically related types and r is a relation telling us how to relate values of those types. To be logically related means that τ1 and τ2 are the concretizations (according to w.η) of some “semantic” type τ . Correspondingly, r is the logical relation V [[τ ]]w.ρ at that semantic type. Thus, when we write E[[τ ]]ρ, α → r in the definition of V [[∀α.τ ]]ρ, this is roughly equivalent to writing E[[τ [τ /α]]]ρ (which our discussion in Section 3.2 might have led the reader to expect to see here instead). The reason for our present formulation is that E[[τ [τ /α]]]ρ is not quite right:
the free variables of τ are interpreted by ρ, but the free variables of τ are dynamic type names whose interpretations are given by w.ρ. It is possible to merge ρ and w.ρ into a unified interpretation ρ , but we feel our present approach is cleaner. Another point of note: since r is uniquely determined from τ1 and τ2 , it is not really necessary to include it in the T [[Ω]]w relation. However, as we shall see in Section 6, formulating the logical relation in this way has the benefit of isolating all of the nonparametricity of our logical relation in the definition of T [[Ω]]w. The term relation E[[τ ]]ρ is very similar to that in previous stepindexed Kripke logical relations [6]. Briefly, it says that two terms are related in an initial world w if whenever the first evaluates to a value under w.σ1 , the second evaluates to a value under w.σ2 , and the resulting stores and values are related in some future world w . The remainder of the definitions in Figure 2 serve to formalize a logical relation for open terms. G[[Γ]]ρ is the logical relation on value substitutions γ, which asserts that related γ’s must map variables in dom(Γ) to related values. D[[Δ]]w is the logical relation on type substitutions. It asserts that related δ’s must map variables in dom(Δ) to types that are related in w. For type variables α bound as α ≈ τ , the δ’s must map α to a type name whose semantic interpretation in w is precisely the logical relation at τ . Analogously to T [[Ω]]w, the relation D[[Δ]]w also includes a relational interpretation ρ, which may be uniquely determined from the δ’s. Finally, the open logical relation Δ; Γ e1 e2 : τ is defined in a fairly standard way. It says that for any starting world w0 , and any type substitutions δ1 and δ2 related in that world, if we are given related value substitutions γ1 and γ2 in any future world w, then δ1 γ1 e1 and δ2 γ2 e2 are related in w as well. 4.2 Why and Where the Steps Matter As we explained in Section 3.2, step indices play a critical role in making the logical relation well-founded. Essentially, whenever we run into an apparent circularity, we “go down a step” by defining an n-level property in terms of an (n−1)-level one. Of course, this trick only works if, at all such “stepping points”, the only way that an adversarial program context could possibly tell whether the nlevel property holds or not is by taking one step of computation and then checking whether the underlying (n−1)-level property holds. Fortunately, this is the case. Since worlds contain relations, and relations contain sets of tuples that include worlds, a na¨ıve construction of these objects would have an inconsistent cardinality. We thus stratify both worlds and relations by a step index: n-level worlds w ∈ Worldn contain n-level interpretations ρ ∈ Interpn , which map type variables to n-level relations; n-level relations R ∈ Reln [τ1 , τ2 ] only contain atoms indexed by a step level k < n and a world w ∈ Worldk . Although our possible worlds have a different structure than in previous work, the technique of mutual world and relation stratification is similar to that used in Ahmed’s thesis [2], as well as recent work by Ahmed, Dreyer and Rossberg [6]. Intuitively, the reason this works in our setting is as follows. Viewed as a judgment, our logical relation asserts that two terms e1 and e2 are logically related for k steps in a world w at a type τ under an interpretation ρ (whose domain contains the free type variables of τ ). Clearly, in order to handle the case where τ is just a type variable α, the relations r in the range of ρ must include atoms at step index k (i.e., the r’s must be in SomeRelk+1 ). But what about the relations in the range of w.ρ? Those relations only come into play in the universal and existential cases of the logical relation for values. Consider the existential case (the universal one is analogous). There, w.ρ pops up in the definition of the relation r that comes from Tk [[Ω]]w. However, that r is only needed in defining the relatedness of the values v1 and v2 at step level k−1 (note the definition of R in the second section of Figure 2). Con-
fact, technically speaking, we consider dom(η) = dom(ρ) to be bound variables of the world w.
6 In
141
sequently, we only need r to include atoms at step k−1 and lower (i.e., r must be in SomeRelk ), so the world w from which r is derived need only be in Worldk . As this discussion suggests, it is imperative that we “go down a step” in the universal and existential cases of the logical relation. For the other cases, it is not necessary to go down a step, although we have the option of doing so. For example, we could define k-level relatedness at pair type τ1 × τ2 in terms of (k−1)-level relatedness at τ1 and τ2 . But since the type gets smaller, there is no need to. For clarity, we have only gone down a step in the logical relation at the points where it is absolutely necessary, and we have used the notation to underscore those points.
former uses int, the latter bool. To show that they are contextually equivalent, it suffices by Soundness to show that each logically approximates the other. We prove only one direction, namely esem1 esem2 : τsem ; the other is proven analogously. Expanding the definitions, we need to show (k, w, esem1 , esem2 ) ∈ En [[τsem ]]∅. Note how each term generates a fresh type name αi in one step, resulting in a package value. Hence all we need to do is come up with a world w satisfying
4.3 Key Properties
where vi is the term component of esemi ’s implementation. We construct w by extending w with mappings that establish the relation between the new type names:
• (k − 1, w ) (k, w), • w .σ1 = w.σ1 , α1 ≈int and w .σ2 = w.σ2 , α2 ≈bool, • (k − 1, w , packα1 , v1 , packα2 , v2 ) ∈ Vn [[τsem ]]∅.
The main results concerning our logical relation are as follows: Theorem 4.1 (Fundamental Property for ) If Δ; Γ e : τ , then Δ; Γ e e : τ .
R := {(k , w , vint , vbool ) ∈ Atomval k−1 [int, bool] | (vint , vbool ) = (1, true) ∨ (vint , vbool ) = (0, false)} r := (int, bool, R)
Theorem 4.2 (Soundness of wrt. Contextual Approximation) If Δ; Γ e1 e2 : τ , then Δ; Γ e1 e2 : τ .
w := wk−1 (α1 ≈int, α2 ≈bool, α→(α1 , α2 ), α→r)
These theorems establish that our logical relation provides a sound technique for proving contextual equivalence of G programs. The proofs of these theorems rely on many technical lemmas, most of which are standard and straightforward to prove. We highlight a few of them here, and refer the reader to the expanded version of this paper for full details of the proofs [16]. One key lemma we have mentioned already is the monotonicity lemma, which states that the logical relation for values is closed under world extension, and therefore belongs to the Rel class of relations. Another key lemma is transitivity of world extension. There are also a group of lemmas—Pitts terms them compatibility lemmas [17]—which show that the logical relation is a precongruence with respect to the constructs of the G language. Of particular note among these are the ones for cast and new. For cast, we must show that cast τ1 τ2 is logically related to itself under a type context Δ assuming that τ1 and τ2 are wellformed in Δ. This boils down to showing that, for logically related type substitutions δ1 and δ2 , it is the case that δ1 τ1 = δ1 τ2 if and only if δ2 τ1 = δ2 τ2 . This follows easily from the fact that δ1 and δ2 , by virtue of being logically related, map the variables in dom(Δ) to types that are syntactically identical up to some bijection on type names. For new, we must show that, if Δ, α≈τ ; Γ e1 e2 : τ , then Δ; Γ new α≈τ in e1 new α≈τ in e2 : τ (assuming Δ Γ and Δ τ ). The proof involves extending the η and ρ components of some given initial world w0 with bindings for the fresh dynamically-generated type name α. The η is extended with α → (α1 , α2 ), where α1 and α2 are the concrete fresh names that are chosen when evaluating the left and right new expressions. The ρ is extended so that the relational interpretation of α is simply the logical relation at type τ . The proof of this lemma is highly reminiscent of the proof of compatibility for ref (reference allocation) in a language with mutable references [6]. Finally, another important compatibility property is type compatibility, i.e., that if Δ τ1 ≈ τ2 and (δ1 , δ2 , ρ) ∈ Dn [[Δ]]w, then Vn [[τ1 ]]ρ = Vn [[τ2 ]]ρ and En [[τ1 ]]ρ = En [[τ2 ]]ρ. The interesting case is when τ1 is a variable α bound in Δ as α ≈ τ2 , and the result in this case follows easily from the definition of D[[Δ, α ≈ τ ]]w.
The first two conditions above are satisfied by construction. To show that the packages are related we need to show the existence of an r with (α1 , α2 , r ) ∈ Tk−1 [[Ω]]w such that (k − ]]ρ, α→r , where τsem = α × 2, w k−2 , v1 , v2 ) ∈ Vn [[τsem i (α → α) × (α → bool). Since αi = w .η (α), r must be (int, bool, Vk−1 [[α]]w .ρ) by definition of T [[Ω]]. Of course, we defined w the way we did so that this r is exactly r. ]]ρ, α→r deThe proof of (k − 2, w k−2 , v1 , v2 ) ∈ Vn [[τsem : composes into three parts, following the structure of τsem
4.4 Examples
The only difference between v1 and v2 is whether the argument x is applied once or twice. Intuitively, either x () diverges, in which case both programs diverge, or else the first application of x terminates, in which case so should the second.
1. (k − 2, w k−2 , 1, true) ∈ Vn [[α]]ρ, α→r This holds because Vn [[α]]ρ, α→r = R. 2. (k − 2, w k−2 , λx:int.(1 − x), λx:bool.¬x) ∈ Vn [[α → α]]ρ, α→r • Suppose we are given related arguments in a future world:
(k , w , v1 , v2 ) ∈ Vn [[α]]ρ, α→r = R.
• Hence either (v1 , v2 ) = (1, true) or (v1 , v2 ) = (0, false). • Consequently, 1 − v1 and ¬v2 will evaluate in one step,
without effects, to values again related by R. • In other words, (k , w , 1 − v1 , ¬v2 ) ∈ En [[α]]ρ, α→r.
3. (k − 2, w k−2 , λx.(x = 0), λx.x) ∈ Vn [[α → bool]]ρ, α→r Like in the previous part, the arguments v1 and v2 will be related by R in some future (k , w ). Therefore v1 = 0 will reduce in one step without effects to v2 , which already is a value. Because of the definition of the logical relation at type bool, this implies (k , w , v1 = 0, v2 ) ∈ En [[bool]]ρ, α→r. Partly Benign Effects. When side effects are introduced into a pure language, they often falsify various equational laws concerning repeatability and order independence of computations. In this section, we offer some evidence that the effect of dynamic type generation is partly benign in that it does not invalidate some of these equational laws. First, consider the following functions: v1 := λx:(unit → τ ). let x = x () in x () v2 := λx:(unit → τ ). x ()
Semaphore. We now return to our semaphore example from Section 2 and show how to prove representation independence for the two different implementations esem1 and esem2 . Recall that the
142
Second, consider the following functions: v1 v2
Wr± τ (e)
:= λx:(unit → τ ).λy:(unit → τ ). let y = y () in x (), y
= let x=e in Wr± τ (x)
def
(if e not a value)
= v Wr± α (v) def ± Wrb (v) = v def ± Wr± (v) = Wr± τ1 (v.1), Wrτ2 (v.2) τ1 ×τ2 def
:= λx:(unit → τ ).λy:(unit → τ ).x (), y ()
The only difference between v1 and v2 is the order in which they call their argument callbacks x and y. Those calls may both result in the generation of fresh type names, but the order in which the names are generated should not matter. Using our logical relation, we can prove that v1 and v2 are contextually equivalent, and so are v1 and v2 . (Due to space considerations, we refer the interested reader to the expanded version of this paper for full proof details [16].) However, as we shall see in the example of e1 and e2 in the next section, our G language does not enjoy referential transparency. This is to be expected, of course, since new is an effectful operation and (in-)equality of type names is observable in the language.
± ∓ Wr± τ1 →τ2 (v) = λx1 :τ1 . Wrτ2 (v Wrτ1 (x1 )) def Wr± = λα. new∓ α in Wr± τ (v α) ∀α.τ (v) def ± Wr∃α.τ (v) = unpack α, x=v in new± α in pack α, Wr± τ (x) as ∃α.τ def + new α in e = new α ≈α in e[α /α] def new− α in e = e def
Figure 3. Wrapping Figure 3 defines a pair of wrapping operators that correspond to these two dual requirements: Wr+ protects an expression e : τe from being used in a non-parametric way, by inserting fresh names for each existential quantifier. Dually, Wr− forces e to behave parametrically by creating a fresh name for each polymorphic instantiation. The definitions extend to other types in the usual functorial manner. Both definitions are interdependent, because roles switch for function arguments. These operators are similar to the typedirected translation that Sumii and Pierce suggest for establishing type abstraction in an untyped language [27] (they propose the descriptive terms “firewall” for Wr+ , and “sandbox” for Wr− ). However, their use of dynamic sealing instead of type generation results in the insertion of runtime coercions to seal/unseal each individual value of abstract type, while our wrapping leaves such values alone. Given these operators, we can go back to our semaphore example: esem1 can now be obtained as Wr+ τsem (esem ) (modulo some harmless η-expansions). This generalises to any ADT: wrapping its implementation positively will guarantee abstraction by making it parametric. We prove that in the next section. Positive wrapping is reminiscent of module sealing (or opaque signature ascription) in ML-style module languages. If we view e as a module and its type τe as a signature, then Wr+ τe (e) corresponds to the sealing operation e :> τe . While module sealing typically only performs static abstraction, wrapping describes the dynamic equivalent [22]. In fact, positive wrapping is precisely how sealing is implemented in Alice ML [23], where the module language is non-parametric otherwise. The correspondence to module sealing motivates our treatment of existential types. Notice that Wr+ causes a fresh type name to be created only once for each existentially quantified type—that is, corresponding to each existential introduction. Another option would be to generate type names with each existential elimination. In fact, such a semantics would arise naturally were we to use a Church encoding of existentials in conjunction with our wrapping for universals. However, in such a semantics, unpacking an existential value twice would have the effect of producing two distinct abstract types. While this corresponds intuitively to the “generativity” of unpack in System F, it is undesirable in the context of dynamic, first-class modules. In particular, in order for an abstract type t defined by some dynamic module M to have some permanent identity (so that it can be referenced by other dynamic modules), it is important that each unpacking of M yields a handle to the same name for t. Moreover, as we show in the next section, our approach to defining wrapping is sufficient to ensure abstraction safety.
5. Wrapping We have seen that parametricity can be re-established in G by introducing name generation in the right place. But what is the “right place” in general? That is, given an arbritrary expression e with polymorphic type τe , how can we systematically transform it into an expression e of the same type τe that is parametric? One obvious—but unfortunately bogus—idea is the following: transform e such that every existential introduction and every universal elimination creates a fresh name for the respective witness or instance type. Formally, apply the following rewrite rules to e: pack τ, e as τ new α≈τ in pack α, e as τ eτ new α≈τ in e α Obviously, this would make every quantified type abstract, so that any cast that tries to inspect it would fail. Or would it? Perhaps surprisingly, the answer is no. To see why, consider the following expressions of type (∃α.τ ) × (∃α.τ ): e1 := let x = pack τ, v in x, x e2 := pack τ, v, pack τ, v They are clearly equivalent in a parametric language (and in fact they are even equivalent in G). Yet rewriting yields: e1 := let x = (new α≈τ in pack α, v) in x, x e2 := new α≈τ in pack α, v, new α≈τ in pack α, v The resulting expressions are not equivalent anymore, because they perform different effects. Here is one distinguishing context: let p = [ ] in unpack α1 , x1 = p.1 in unpack α2 , x2 = p.2 in equal? α1 α2 Although the representation type τ is not disclosed as such, sharing between the two abstract types in e1 is. In a parametric language, that would not be possible. In order to introduce effects uniformly, and to hide internal sharing, the transformation we are looking for needs to be defined on the structure of types, not terms. Roughly, for each quantifier occurring in τe we need to generate one fresh type name. That is, instead of transforming e itself, we simply wrap it with some expression that introduces the necessary names at the boundary, by induction on the type τe . In fact, we can refine the problem further. When looking at a G expression e, what do we actually mean by “making it parametric”? We can mean two different things: either ensuring that e behaves parametrically, or dually, that any context treats e parametrically. In the former case, we are protecting the context against e, in the latter we protect e against malicious contexts. The latter is what is sometimes referred to as abstraction safety.
6. Parametric Reasoning The logical relation developed in Section 4 enables us to do nonparametric reasoning about equivalence of G programs. It also
143
Tn◦ [[Ω]]w
def
=
{(τ1 , τ2 , (τ1 , τ2 , R)) | τi ∧ w.σi τi ≈ τi ∧ R ∈ Reln [τ1 , τ2 ]} (everything else as in Figure 2) Figure 4. Parametric Logical Relation
enables us to do parametric reasoning, but only indirectly: we have to explicitly deal with the effects of new and to define worlds containing relations between type names. It would be preferable if we were able to do parametric reasoning directly. For example, given two expressions e1 , e2 that do not use casts, and assuming that the context does not do so either, we should be able to reason about equivalence of e1 and e2 in a manner similar to what we do when reasoning about System F.
6.2 Examples Semaphore. Consider our running example of the semaphore module again. Using the parametric relation, we can prove that the two implementations are related without actually reasoning about type generation. That aspect is covered once and for all by the Wrapping Theorem. Recall the two implementations, here given in unwrapped form: esem1 := pack int, 1, λx: int .(1 − x), λx: int .(x = 0) as τsem esem2 := pack bool, true, λx: bool .¬x, λx: bool .x as τsem
6.1 A Parametric Logical Relation
We can prove esem1 ◦ esem2 : τsem using conventional parametric reasoning about polymorphic terms. Now define esem1 = + Wr+ τsem (esem1 ) and esem2 = Wrτsem (esem2 ), which are semantically equivalent to the original definitions in Section 2.3. The Wrapping Theorem then immediately tells us that esem1 esem2 : τsem .
Thanks to the modular formulation of our logical relation in Figure 2, it is easy to modify it so that it becomes parametric. All we need to do is swap out the definition of T [[Ω]]w, which relates types as data. Figure 4 gives an alternative definition that allows choosing an arbitrary relation between arbitrary types. Everything else stays exactly the same. We decorate the set of parametric logical relations thus obtained with ◦ (i.e., V ◦ , E ◦ , etc.) to distinguish them from the original ones. Likewise, we write ◦ for the notion of parametric logical approximation defined as in Figure 2 but in terms of the parametric relations. For clarity, we will refer to the original definition as the non-parametric logical relation. This modification gives us a seemingly parametric definition of logical approximation for G terms. But what does that actually mean? What is the relation between parametric and non-parametric logical approximation and, ultimately, contextual approximation? Since the language is not parametric, clearly, parametrically equivalent terms generally are not contextually equivalent. The answer is given by the wrapping functions we defined in the previous section. The following theorem connects the two notions of logical relation and approximation that we have introduced:
A Free Theorem. We can use the parametric relation for proving free theorems [30] in G. For example, for any g : ∀α.α → α in G it holds that Wr− (g) either diverges for all possible arguments τ and v : τ , or it returns v in all cases. We first apply the Fundamental Property for to relate g to itself in E, then transfer this to E ◦ for Wr− (g) using the Wrapping Theorem. From there the proof proceeds in the usual way.
7. Syntactic vs. Semantic Parametricity The primary motivation for our parametric relation in the previous section was to enable more direct parametric reasoning about the result of (positively) wrapping System F terms. However, it is also possible to use our parametric relation to reason about terms that are syntactically, or intensionally, non-parametric (i.e., that use cast’s), so long as they are semantically, or extensionally, parametric (i.e., the use of cast is not externally observable). For example, consider the following two polymorphic functions of type ∀α.τα (here, let b2i = λx:bool. if x then 1 else 0):
Theorem 6.1 (Wrapping for ◦ ) + 1. If e1 ◦ e2 : τ , then Wr+ τ (e1 ) Wrτ (e2 ) : τ . − ◦ 2. If e1 e2 : τ , then Wrτ (e1 ) Wr− τ (e2 ) : τ .
τα := ∃β. (α × α → β) × (β → α) × (β → α) g1 := λα. pack α × α, λp.p, λx.(x.1), λx.(x.2) as τα g2 := λα. cast τbool τα (pack int, λp:(bool × bool). b2i(p.1) + 2×b2i (p.2), λx:int. x mod 2 = 0, λx:int. x div 2 = 0 as τbool ) (g1 α)
This theorem justifies the definition of the parametric logical relation. At the same time it can be read as a correctness result for the wrapping operators: it says that whenever we can relate two terms using parametric reasoning, then the positive wrappings of the first term contextually approximates the positive wrapping of the second. Dually, once any properly related terms are wrapped negatively, they can safely be passed to any term that depends on its context behaving parametrically. What can we say about the content of the parametric relation? Obviously, it cannot contain arbitrary non-parametric G terms— e.g., cast τ1 τ2 is not even related to itself in E ◦ . However, we still obtain the following restricted form of the fundamental property:
These two functions take a type argument α and return a simple generic ADT for pairs over α. But g2 is more clever about it and specializes the representation for α = bool. In that case, it packs both components into the two least significant bits of a single integer. For all other types, g2 falls back to the generic implementation from g1 . Using the parametric relation, we will be able to show that Wr+ (g1 ) Wr+ (g2 ) : ∀α.τα . One might find this surprising, since g2 is syntactically non-parametric, returning different implementations for different instantiations of its type argument. However, since the two possible implementations g2 returns are extensionally equivalent to each other, g2 is semantically indistinguishable from the syntactically parametric g1 . Formally: Assume that τ1 , τ2 are the types and Rα ∈ Rel[τ1 , τ2 ] is the relation the context picks, parametrically, for α. If τ2 = bool, the rest of the proof is straightforward. Otherwise, we do not know
Theorem 6.2 (Fundamental Property for ◦ ) If Δ; Γ e : τ and e is cast-free, then Δ; Γ e ◦ e : τ . In particular, this implies that any well-typed System F term is parametrically related to itself. The relation will also contain terms with cast, but only if the use of cast does not violate parametricity. (We discuss this further in Section 7.) Along the same lines, we can show that our parametric logical relation is sound w.r.t. contextual approximation, if the definition of the latter is limited to quantifying only over cast-free contexts.
144
Vn± [[α]]ρ Vn± [[b]]ρ Vn± [[τ × τ ]]ρ
def
Vn± [[τ → τ ]]ρ
def
Vn± [[∀α.τ ]]ρ
def
Vn± [[∃α.τ ]]ρ
def
En± [[τ ]]ρ
def
Tn+ [[Ω]]w Tn− [[Ω]]w
def
= = def = def
=
=
= =
= =
def
ρ(α).Rn {(k, w, c, c) ∈ Atomn [b, b]} {(k, w, v1 , v1 , v2 , v2 ) ∈ Atomn [ρ1 (τ × τ ), ρ2 (τ × τ )] | (k, w, v1 , v2 ) ∈ Vn± [[τ ]]ρ ∧ (k, w, v1 , v2 ) ∈ Vn± [[τ ]]ρ} {(k, w, λx:τ1 .e1 , λx:τ2 .e2 ) ∈ Atomn [ρ1 (τ → τ ), ρ2 (τ → τ )] | ∀(k , w , v1 , v2 ) ∈ Vn∓ [[τ ]]ρ. (k , w ) (k, w) ⇒ (k , w , e1 [v1 /x], e2 [v2 /x]) ∈ En± [[τ ]]ρ} {(k, w, λα.e1 , λα.e2 ) ∈ Atomn [ρ1 (∀α.τ ), ρ2 (∀α.τ )] | ∀(k , w ) (k, w). ∀(τ1 , τ2 , r) ∈ Tk∓ [[Ω]]w . (k , w , e1 [τ1 /α], e2 [τ2 /α]) ∈ En± [[τ ]]ρ, α→r} {(k, w, pack τ1 , v1 , pack τ2 , v2 ) ∈ Atomn [ρ1 (∃α.τ ), ρ2 (∃α.τ )] | ∃r. (τ1 , τ2 , r) ∈ Tk± [[Ω]]w ∧ (k, w, v1 , v2 ) ∈ Vn± [[τ ]]ρ, α→r} {(k, w, e1 , e2 ) ∈ Atomn [ρ1 (τ ), ρ2 (τ )] | ∀j < k. ∀σ1 , v1 . (w.σ1 ; e1 →j σ1 ; v1 ) ⇒ ∃w , v2 . (k − j, w ) (k, w) ∧ w .σ1 = σ1 ∧ (w.σ2 ; e2 →∗ w .σ2 ; v2 ) ∧ (k − j, w , v1 , v2 ) ∈ Vn± [[τ ]]ρ} Tn◦ [[Ω]]w Tn [[Ω]]w
Dn+ [[Δ]]w Dn− [[Δ]]w
Δ; Γ e1 ± e2 : τ ⇔ def
def
= =
def
Dn◦ [[Δ]]w Dn [[Δ]]w
Δ; Γ e1 : τ ∧ Δ; Γ e2 : τ ∧ ∀n ≥ 0, ∀w0 ∈ Worldn . ∀(δ1 , δ2 , ρ) ∈ Dn∓ [[Δ]]w0 . ∀(k, w, γ1 , γ2 ) ∈ G∓ n [[Γ]]ρ. (k, w) (n, w0 ) ⇒ (k, w, δ1 γ1 (e1 ), δ2 γ2 (e2 )) ∈ En± [[τ ]]ρ Figure 5. Polarized Logical Relations
anything about τ1 and Rα , because τ1 and τ2 are related in T ◦ . Nevertheless, we can construct a suitable relational interpretation Rβ ∈ Rel[τ1 × τ1 , int] for the type β:
Here is a somewhat contrived example to illustrate the point. Consider the following two polymorphic functions of type ∀α.τα : τα := ∃β. (α → β) × (β → α) f1 := λα. cast τint τα (pack int, λx:int.x+1, λx:int.x as τint ) (pack α, λx:α.x, λx:α.x as τα ) f2 := λα. cast τint τα (pack int, λx:int.x, λx:int.x+1 as τint ) (pack α, λx:α.x, λx:α.x as τα )
Rβ := {(k, w, v, v , 0) | (k, w, v, false), (k, w, v , false) ∈ Rα } ∪ {(k, w, v, v , 1) | (k, w, v, true), (k, w, v , false) ∈ Rα } ∪ {(k, w, v, v , 2) | (k, w, v, false), (k, w, v , true) ∈ Rα } ∪ {(k, w, v, v , 3) | (k, w, v, true), (k, w, v , true) ∈ Rα } As it turns out, we do not need to know much about the structure of Rα to define Rβ . What we are relying on here is only the knowledge that all values in Rα are well-typed, which is built into our definition of Rel. From that we know that there can never be any other value than true or false on the right side of the relation Rα . Hence we can still enumerate all possible cases to define Rβ , and do a respective case distinction when proving equivalence of the projection operations. Interestingly, it seems that our proof relies critically on the fact that our logical relations are restricted to syntactically well-typed terms. Were we to lift this restriction, we would be forced (it seems) to extend the definition of Rβ with a “junk” case, but the calls to b2i in g2 would get stuck if applied to non-boolean values. We leave further investigation of this observation to future work.
These functions take a type argument α and return a simple ADT β. Values of type α can be injected into β, and projected out again. However, both functions specialize the behavior of this ADT for type int—for integers, injecting n and projecting again will give back not n, but rather n + 1. This is true for both functions, but they implement it in a different way. We want to prove that both implementations are equivalent under wrapping using a form of parametric reasoning. However, we cannot do that using the parametric relation from the previous section—since the functions do not behave parametrically (i.e., they return observably different packages for different instantiations of their type argument), they will not be related in E ◦ . To support that kind of reasoning, we need a more refined treatment of parametricity in the logical relation. The idea is to separate the two aforementioned aspects of parametricity. Consequently, we are going to have a pair of separate relations, E + and E − . The former enforces parametric usage, the latter parametric behavior. Figure 5 gives the definition of these relations. We call them polarized, because they are mutually dependent and the polarity (+ or −) switches for contravariant positions, i.e., for function arguments and for universal quantifiers. Intuitively, in these places, term and context switch roles. Except for the consistent addition of polarities, the definition of the polarized relations again only represents a minor modification of the original one.7 We merely refine the definition of the type re-
8. Polarized Logical Relations The parametric relation is useful for proving parametricity properties about (the positive wrappings of) G terms. However, it is all-ornothing: it can only be used to prove parametricity for terms that expect to be treated parametrically and also behave parametrically— cf. the two dual aspects of parametricity described in Section 5. We might also be interested in proving representation independence for terms that do not behave parametrically themselves (in either the syntactic or semantic sense considered in the previous section). One situation where this might show up is if we want to show representation independence for generic ADTs that (like the ones in Section 7) return different results for different instantiations of their type arguments, but where (unlike in Section 7) the difference is not only syntactic but also semantic.
7 In
fact, all four relations can easily be formulated in a single unified definition indexed by ι ::= | ◦ | + | −. We refrained from doing so here for the sake of clarity; see the expanded version of this paper for details [16].
145
If τ1 = int, then we know from the definition of T − that τ2 = int, too. We hence know that both sides will evaluate to the specialized version of the ADT. Since we are in E + , we get to pick some (τ1 , τ2 , r ) ∈ T + [[Ω]]w as the interpretation of β, where the choice of r is up to us. The natural choice is to use τ1 = τ2 = int with the relation r = (int, int, {(k, w, n + 1, n) | n ∈ Z}). The rest of the proof is then straightforward. If τ1 = int we similarly know that τ2 = int from the definition of T − . Hence, both sides use the default implementations, which are trivially related in E + , thanks to Corollary 8.3. Finally, applying the Wrapping Theorem 8.1, we can conclude that Wr+ (f1 ) Wr+ (f2 ) : ∀α.τα , and hence by Soundness, Wr+ (f1 ) Wr+ (f2 ) : ∀α.τα . Note how we relied on the knowledge that τ1 and τ2 can only be int at the same time. This holds for types related in T − but not in T + or T ◦ . If we had tried to do this proof in E ◦ , the types τ1 and τ2 would have been related by T ◦ only, which would give us too little information to proceed with the necessary case distinction.
eG ∈
Wr+
E+
eG ∈ E Wr−
Wr− E ◦ eF
E−
Wr+
Figure 6. Relating the Relations lation T [[Ω]]w to distinguish polarity: in the positive case it behaves parametrically (i.e., allowing an arbitrary relation) and in the negative case non-parametrically (i.e., demanding r be the logical relation at some type). Thus, existential types behave parametrically in E + but non-parametrically in E − , and vice versa for universals.
9. Recursive Types
8.1 Key Properties
We now add iso-recursive types to G and call the result Gμ :
The way in which polarities switch in the polarized relations mirrors what is going on in the definition of wrapping. That of course is no accident, and we can show the following theorem that relates the polarized relations with the non-parametric and parametric ones through uses of wrapping:
Types Values Terms
If e1 If e1 If e1 If e1
::= ::= ::=
. . . | μα.τ . . . | roll v as τ . . . | roll e as τ | unroll e
The extensions to the semantics are standard and therefore omitted— they do not affect the type store. Also, the definition of contextual equivalence does not change (except there are more contexts).
Theorem 8.1 (Wrapping for ± ) 1. 2. 3. 4.
τ v e
+ + e2 : τ , then Wr+ τ (e1 ) Wrτ (e2 ) : τ . − − e2 : τ , then Wrτ (e1 ) Wr− τ (e2 ) : τ . ◦ − + e2 : τ , then Wr− τ (e1 ) Wrτ (e2 ) : τ . ◦ + − e2 : τ , then Wrτ (e1 ) Wr+ τ (e2 ) : τ .
9.1 Extending the Logical Relations The step-indexing that we used in defining our logical relations makes it very easy to adapt them to Gμ . There are two natural ways in which we could define the value relation at a recursive type:
Moreover, we can show that the inverse directions of these implications require no wrapping at all:
1. Vnι [[μα.τ ]]ρ = {(k, w, roll v1 , roll v2 ) ∈ Atomn [. . . ] | (k, w, v1 , v2 ) ∈ Vkι [[τ ]]ρ, α→Vkι [[μα.τ ]]ρ} def 2. Vnι [[μα.τ ]]ρ = {(k, w, roll v1 , roll v2 ) ∈ Atomn [. . . ] | (k, w, v1 , v2 ) ∈ Vkι [[τ [μα.τ /α]]]ρ} def
Theorem 8.2 (Inclusion for ± ) 1. If e1 e2 : τ or e1 ◦ e2 : τ , then e1 + e2 : τ . 2. If e1 − e2 : τ , then e1 e2 : τ and e1 ◦ e2 : τ .
For ι ∈ {, ◦}—i.e., for the non-parametric and parametric forms of the logical relation—the above two formulations are equivalent due to the validity of a standard substitution property. Unfortunately, though, we do not have such a property for the polarized relation. In fact, for ι ∈ {+, −}, the first definition wrongly records a fixed polarity for α. It is thus crucial that we choose the second one; only then do all key properties continue to hold in Gμ .
This theorem can equivalently be stated as: E − ⊆ E ⊆ E + and E− ⊆ E◦ ⊆ E+. Note that Theorem 6.1 follows directly from Theorems 8.1 and 8.2. Similarly, the following property follows from Theorem 8.2 together with Theorem 4.1: Corollary 8.3 (Fundamental Property for + ) If Δ; Γ e : τ , then Δ; Γ e + e : τ .
9.2 Extending the Wrapping
Interestingly, compatibility does not hold for ± (consider the polarities in the rule for application), which has the consequence that we cannot show Corollary 8.3 directly. For a similar reason, we cannot show any such property for − at all. Figure 6 depicts all of the above properties in a single diagram. Unlabeled arrows denote inclusion, while labeled arrows denote the wrapping that maps one relation to the other. The ∈-operators show the fundamental properties for the respective relations, i.e., which class of terms are included (G terms or F terms).
How can we upgrade the wrapping to account for recursive types? Given an argument of type μα.τ , the basic idea is to first unfold it to type τ [μα.τ /α], then wrap it at that type, and finally fold the result back to type μα.τ . Of course, since τ [μα.τ /α] may be larger than μα.τ , a direct implementation of this idea will not result in a well-founded definition. The solution is to use a fixed-point (definable in terms of recursive types, of course), which gives us a handle on the wrapping function we are in the middle of defining. Figure 7 shows the new definition. We first index the wrapping by an environment ϕ that maps recursive type variables α to wrappings for those variables. Roughly, the wrapping at type μα.τ under environment ϕ is a recursive function F , defined in terms of the wrapping at type τ under environment ϕ, α → F . Since the bound variable of a recursive type may occur in positions of different polarity, we actually need two mutually recursive functions and then select the right one depending on the polarity. The cognoscenti will recognize this as a
8.2 Example Getting back to our motivating example from the beginning of the section, it is essentially straightforward to prove that f1 + f2 : ∀α.τα . The proof proceeds as usual, except that we have to make a case distinction when we want to show that the function bodies are related in E + . At that point, we are given a triple (τ1 , τ2 , r) ∈ T − [[Ω]]w.
146
relies on parametricity (especially for existential types) to hide an ADT’s representation from clients [15]. The latter approach is typically employed in untyped languages, which do not have the ability to place static restrictions on clients. Consequently, data hiding has to be enforced on the level of individual values. For that, languages provide means for generating unique names and using them as keys for dynamically sealing values. A value sealed by a given key can only be inspected by principals that have access to the key [27]. Dynamic type generation as we employ it [21, 29, 22] can be seen as a middle ground, because it bears resemblance to both approaches. As in the dynamic approach, we cannot rely on parametricity and instead generate dynamic names to protect abstractions. However, these are type-level names, not term-level names, and they only “seal” type information. In particular, individual values of abstract type are still directly represented by the underlying representation type, so that crossing abstraction boundaries has no runtime cost. In that sense, we are closer to the static approach. Another approach to reconciling type abstraction and type analysis has been proposed by Washburn and Weirich [31]. They introduce a type system that tracks information flow for terms and types-as-data. By distinguishing security levels, the type system can statically prevent unauthorized inspection of types by clients.
Wr± = v (if α ∈ / dom(ϕ)) α;ϕ (v) def Wr± = ϕ± (α) v (if α ∈ dom(ϕ)) α;ϕ (v) def + + Wr± μα.τ ;ϕ (v) = letrec f = λx. roll (Wrτ ;ϕ (unroll x)[μα.τ /α]) − (unroll x)[μα.τ /α]) and f = λx. roll (Wr− τ ;ϕ (where ϕ = ϕ, α→(f + , f − )) in f ± v def
(other cases as before except for the consistent addition of ϕ) Figure 7. Wrapping for Gμ polarized variant of the so-called syntactic projection function associated with a recursive type [8]. Note that the environment only plays a role for recursive types, and that for any τ that does not involve recursive types, Wr± τ ;∅ is ± the same as our old wrapping Wr± τ from Section 5. Taking Wrτ to be shorthand for Wr± , all our old wrapping theorems for G τ ;∅ continue to hold for Gμ . Full proofs of these theorems are given in the expanded version of this paper [16].
10. Towards Full Abstraction
Multi-Language Interoperation. The closest work to ours is that of Matthews and Ahmed [13]. They describe a pair of mutually recursive logical relations that deal with the interoperation between a typed language (“ML”) and an untyped language (“Scheme”). Unlike in G, parametric behavior is hard-wired into their ML side: polymorphic instantiation unconditionally performs a form of dynamic sealing to protect against the non-parametric Scheme side. (In contrast, we treat new as its own language construct, orthogonal to universal types.) Dynamic sealing can then be defined in terms of the primitive coercion operators that bridge between the ML and Scheme sides. These coercions are similar to our (metalevel) wrapping operators, but ours perform type-level sealing, not term-level sealing. The logical relations in Matthews and Ahmed’s formalism are somewhat reminiscent of E ◦ and E, although theirs are distinct logical relations for two languages, while ours are for a single language and differ only in the definition of T [[Ω]]w. In order to prove the fundamental property for their relations, they prove a “bridge lemma” transferring relatedness in one language to the other via coercions. This is analogous to our Wrapping Theorem for ◦ , but the latter is an independent theorem, not a lemma. Also, they do not propose anything like our polarized logical relations. A key technical difference is that their formulation of the logical relations does not use possible worlds to capture the type store (the latter is left implicit in their operational semantics). Unfortunately, this resulted in a significant flaw in their paper [4]. They have since reportedly fixed the problem—independently of our work—using a technique similar to ours, but they have yet to write up the details.
◦
The definition of the parametric relation E (including the extension for recursive types) is largely very similar to that of a typical step-indexed logical relation EFµ for Fμ , i.e., System F extended with pairs, existentials and iso-recursive types [3]. The main difference is the presence of worlds, but they are not actually used in a particularly interesting way in E ◦ . Therefore, one might expect that any two Fμ terms related by the hypothetical EFµ would also be related by E ◦ and vice versa. However, this is not obvious: Gμ is more expressive than Fμ , i.e., terms in the parametric relation can contain non-trivial uses of casts (e.g., the generic ADT for pairs from Section 7), and there is no evident way to back-translate these terms into Fμ , as would be needed for function arguments. That invalidates a proof approach like the one taken by Ahmed and Blume [5]. Ultimately, the property we would like to be able to show is that the embedding of Fμ into Gμ by positive wrapping is fully abstract: + e1 Fµ e2 : τ ⇐⇒ Wr+ τ (e1 ) Wrτ (e2 ) : τ
This equivalence is even stronger than the one about logical relatedness in EFµ and E ◦ , because is only sound w.r.t. contextual approximation, not complete. Since Fμ is a fragment of Gμ , and Fμ contexts cannot observe any difference between an Fμ term and its wrapping, the direction from right to left, called equivalence reflection, is not hard to show. Theorem 10.1 (Equivalence Reflection) If Δ; Γ Fµ e1 : τ and Δ; Γ Fµ e2 : τ + and Δ; Γ Wr+ τ (e1 ) Wrτ (e2 ) : τ , then Δ; Γ e1 Fµ e2 : τ .
Proof Methods. Logical relations in various forms are routinely used to reason about program equivalence and type abstraction [20, 14, 17, 3]. In particular, Ahmed, Dreyer and Rossberg recently applied step-indexed logical relations with possible worlds to reason about type abstraction for a language with higher-order state [6]. State in G is comparatively benign, but still requires a circular definition of worlds that we stratify using steps. Pitts and Stark used logical relations to reason about program equivalence in a language with (term-level) name generation [18] and subsequently generalized their technique to handle mutable references [19]. Sumii and Pierce use them for proving secrecy results for a language with dynamic sealing [26], where generated names are used as keys. Their logical relation uses a form of possible world very similar to ours, but tying relational interpretations to
Unfortunately, it is not known to us whether the other direction, equivalence preservation, holds as well. We conjecture that it does, but are not aware of any suitable technique to prove it. Note that while equivalence reflection also holds for F and G— i.e., in the absence of recursive types—equivalence preservation does not, because non-termination is encodable in G but not in F.
11. Related Work Type Generation vs. Other Forms of Data Abstraction. Traditionally, authors have distinguished between two complementary forms of data abstraction, sometimes dubbed the static and the dynamic approach [13]. The former is tied to the type system and
147
term-level private keys instead of to type names. Their worlds come into play in the interpretation of the type bits of encrypted data, whereas in our setup the worlds are important in the interpretation of universal and existential types. In another line of work, Sumii and Pierce have used bisimulations to establish abstraction results for both untyped and polymorphic languages [27, 28]. However, none of the languages they investigate mixes the two paradigms. Grossman, Morrisett and Zdancewic have proposed the use of abstraction brackets for syntactically tracing abstraction boundaries [10] during program execution. However, this is a comparatively weak method that does not seem to help in proving parametricity or representation independence results.
[6] Amal Ahmed, Derek Dreyer, and Andreas Rossberg. State-dependent representation independence. In POPL, 2009. [7] Andrew W. Appel and David McAllester. An indexed model of recursive types for foundational proof-carrying code. TOPLAS, 23(5):657–683, 2001. [8] Karl Crary and Robert Harper. Syntactic logical relations for polymorphic and recursive types. In Computation, Meaning and Logic: Articles dedicated to Gordon Plotkin. 2007. [9] Jean-Yves Girard. Interpr´etation fonctionelle et e´ limination des coupures de l’arithm´etique d’ordre sup´erieur. PhD thesis, Universit´e Paris VII, 1972. [10] Dan Grossman, Greg Morrisett, and Steve Zdancewic. Syntactic type abstraction. TOPLAS, 22(6):1037–1080, 2000.
12. Conclusion and Future Work
[11] Robert Harper and John C. Mitchell. Parametricity and variants of Girard’s J operator. Information Processing Letters, 1999.
In traditional static languages, type abstraction is established by parametric polymorphism. This approach no longer works when dynamic typing features like casts, typecase, or reflection are added to the mix. Dynamic type generation addresses this problem. In this paper, we have shown that dynamic type generation succeeds in recovering type abstraction. More specifically: (1) we presented a step-indexed logical relation for reasoning about program equivalence in a non-parametric language with cast and type generation; (2) we showed that parametricity can be re-established systematically using a simple type-directed wrapping, which then can be reasoned about using a parametric variant of the logical relation; (3) we showed that parametricity can be refined into parametric behavior and parametric usage and gave a polarized logical relation that distinguishes these dual notions, thereby handling more subtle examples. The concept of a polarized logical relation seems novel, and it remains to be seen what else it might be useful for. Interestingly, all our logical relations can be defined as a single family differing only in the interpretation T of types-as-data. An open question is whether the wrapping, when seen as an embedding of Fμ into Gμ , is fully abstract. We conjecture that it is, but we were only able to show equivalence reflection, not equivalence preservation. Proving full abstraction remains an interesting challenge for future work. On the practical side, we would like to scale our logical relation to handle a more realistic language like ML. Unfortunately, wrapping cannot easily be extended to a type of mutable references. However, we believe that our approach still scales to a large class of languages, so long as we instrument it with a distinction between module and core levels. Specifically, note that wrapping only does something “interesting” for universal and existential types, and is the identity (modulo η-expansion) otherwise. Thus, for a language like Standard ML, which does not support firstclass polymorphism—or Alice ML, which supports modules-asfirst-class-values, but not existentials—wrapping could be confined to the module level (as part of the implementation of opaque signature ascription). For core-level types it could just be the identity. This is a real advantage of type generation over dynamic sealing since, for the latter, the need to seal/unseal individual values of abstract type precludes any attempt to confine wrapping to modules.
[12] Robert Harper and Greg Morrisett. Compiling polymorphism using intensional type analysis. In POPL, 1995. [13] Jacob Matthews and Amal Ahmed. Parametric polymorphism through run-time sealing, or, theorems for low, low prices! In ESOP, 2008. [14] John C. Mitchell. Representation independence and data abstraction. In POPL, 1986. [15] John C. Mitchell and Gordon D. Plotkin. Abstract types have existential type. TOPLAS, 10(3):470–502, 1988. [16] Georg Neis. Non-parametric parametricity. Universit¨at des Saarlandes, 2009.
Master’s thesis,
[17] Andrew Pitts. Typed operational reasoning. In Benjamin C. Pierce, editor, Advanced Topics in Types and Programming Languages, chapter 7. MIT Press, 2005. [18] Andrew Pitts and Ian Stark. Observable properties of higher order functions that dynamically create local names, or: What’s new? In MFCS, volume 711 of LNCS, 1993. [19] Andrew Pitts and Ian Stark. Operational reasoning for functions with local state. In HOOTS, 1998. [20] John C. Reynolds. Types, abstraction and parametric polymorphism. In Information Processing, 1983. [21] Andreas Rossberg. Generativity and dynamic opacity for abstract types. In PPDP, 2003. [22] Andreas Rossberg. Dynamic translucency with abstraction kinds and higher-order coercions. In MFPS, 2008. [23] Andreas Rossberg, Didier Le Botlan, Guido Tack, Thorsten Brunklaus, and Gert Smolka. Alice ML through the looking glass. In TFP, volume 5, 2004. [24] Peter Sewell. Modules, abstract types, and distributed versioning. In POPL, 2001. [25] Peter Sewell, James Leifer, Keith Wansbrough, Francesco Zappa Nardelli, Mair Allen-Williams, Pierre Habouzit, and Viktor Vafeiadis. Acute: High-level programming language design for distributed computation. JFP, 17(4&5):547–612, 2007. [26] Eijiro Sumii and Benjamin C. Pierce. Logical relations for encryption. JCS, 11(4):521–554, 2003. [27] Eijiro Sumii and Benjamin C. Pierce. A bisimulation for dynamic sealing. TCS, 375(1–3):161–192, 2007.
References [1] Mart´ın Abadi, Luca Cardelli, Benjamin Pierce, and Didier R´emy. Dynamic typing in polymorphic languages. JFP, 5(1):111–130, 1995.
[28] Eijiro Sumii and Benjamin C. Pierce. A bisimulation for type abstraction and recursion. JACM, 54(5):1–43, 2007.
[2] Amal Ahmed. Semantics of Types for Mutable State. PhD thesis, Princeton University, 2004.
[29] Dimitrios Vytiniotis, Geoffrey Washburn, and Stephanie Weirich. An open and shut typecase. In TLDI, 2005.
[3] Amal Ahmed. Step-indexed syntactic logical relations for recursive and quantified types. In ESOP, 2006.
[30] Philip Wadler. Theorems for free! In FPCA, 1989. [31] Geoffrey Washburn and Stephanie Weirich. Generalizing parametricity using information flow. In LICS, 2005.
[4] Amal Ahmed. Personal communication, 2009.
[32] Stephanie Weirich. Type-safe cast. JFP, 14(6):681–695, 2004.
[5] Amal Ahmed and Matthias Blume. Typed closure conversion preserves observational equivalence. In ICFP, 2008.
148
Finding Race Conditions in Erlang with QuickCheck and PULSE Koen Claessen Michał Pałka Nicholas Smallbone
John Hughes Hans Svensson Thomas Arts
Chalmers University of Technology, Gothenburg, Sweden
[email protected] [email protected] [email protected]
Chalmers University of Technology and Quviq AB
[email protected] [email protected] [email protected]
Abstract
General Terms Keywords
1.
Erlang Training and Consulting
[email protected]
a multi-core processor. In that case, one would really benefit from a hierarchical approach to testing legacy code in order to simplify debugging of faults encountered. The Erlang programming language (Armstrong 2007) is designed to simplify concurrent programming. Erlang processes do not share memory, and Erlang data structures are immutable, so the kind of data races which plague imperative programs, in which concurrent processes race to read and write the same memory location, simply cannot occur. However, this does not mean that Erlang programs are immune to race conditions. For example, the order in which messages are delivered to a process may be nondeterministic, and an unexpected order may lead to failure. Likewise, Erlang processes can share data, even if they do not share memory—the file store is one good example of shared mutable data, but there are also shared data-structures managed by the Erlang virtual machine, which processes can race to read and write. Industrial experience is that the late discovery of race conditions is a real problem for Erlang developers too (Cronqvist 2004). Moreover, these race conditions are often caused by design errors, which are particularly expensive to repair. If these race conditions could be found during unit testing instead, then this would definitely reduce the cost of software development. In this paper, we describe tools we have developed for finding race conditions in Erlang code during unit testing. Our approach is based on property-based testing using QuickCheck (Claessen and Hughes 2000), in a commercial version for Erlang developed by Quviq AB (Hughes 2007; Arts et al. 2006). Its salient features are described in section 3. We develop a suitable property for testing parallel code, and a method for generating parallel test cases, in section 4. To test a wide variety of schedules, we developed a randomizing scheduler for Erlang called PULSE, which we explain in section 5. PULSE records a trace during each test, but interpreting the traces is difficult, so we developed a trace visualizer which is described in section 6. We evaluate our tools by applying them to an industrial case study, which is introduced in section 2, then used as a running example throughout the paper. This code was already known to contain bugs (thanks to earlier experiments with QuickCheck in 2005), but we were previously unable to diagnose the problems. Using the tools described here, we were able to find and fix two race conditions, and identify a fundamental flaw in the API.
We address the problem of testing and debugging concurrent, distributed Erlang applications. In concurrent programs, race conditions are a common class of bugs and are very hard to find in practice. Traditional unit testing is normally unable to help finding all race conditions, because their occurrence depends so much on timing. Therefore, race conditions are often found during system testing, where due to the vast amount of code under test, it is often hard to diagnose the error resulting from race conditions. We present three tools (QuickCheck, PULSE, and a visualizer) that in combination can be used to test and debug concurrent programs in unit testing with a much better possibility of detecting race conditions. We evaluate our method on an industrial concurrent case study and illustrate how we find and analyze the race conditions. Categories and Subject Descriptors ging]: Distributed debugging
Ulf Wiger
D.2.5 [Testing and Debug-
Verification
QuickCheck, Race Conditions, Erlang
Introduction
Concurrent programming is notoriously difficult, because the nondeterministic interleaving of events in concurrent processes can lead software to work most of the time, but fail in rare and hardto-reproduce circumstances when an unfortunate order of events occurs. Such failures are called race conditions. In particular, concurrent software may work perfectly well during unit testing, when individual modules (or “software units”) are tested in isolation, but fail later on during system testing. Even if unit tests cover all aspects of the units, we still can detect concurrency errors when all components of a software system are tested together. Timing delays caused by other components lead to new, previously untested, schedules of actions performed by the individual units. In the worst case, bugs may not appear until the system is put under heavy load in production. Errors discovered in these late stages are far more expensive to diagnose and correct, than errors found during unit testing. Another cause of concurrency errors showing up at a late stage is when well-tested software is ported from a single-core to
Permission to make digital or hard copies of all or part of this work for personal or classroom use is granted without fee provided that copies are not made or distributed for profit or commercial advantage and that copies bear this notice and the full citation on the first page. To copy otherwise, to republish, to post on servers or to redistribute to lists, requires prior specific permission and/or a fee. ICFP’09, August 31–September 2, 2009, Edinburgh, Scotland, UK. c 2009 ACM 978-1-60558-332-7/09/08. . . $5.00 Copyright
2.
Introducing our case study: the process registry
We begin by introducing the industrial case that we apply our tools and techniques to. In Erlang, each process has a unique, dynamically-assigned identifier (“pid”), and to send a message to
149
a process, one must know its pid. To enable processes to discover the pids of central services, such as error logging, Erlang provides a process registry—a kind of local name server—which associates static names with pids. The Erlang VM provides operations to register a pid with a name, to look up the pid associated with a name, and to unregister a name, removing any association with that name from the registry. The registry holds only live processes; when registered processes crash, then they are automatically unregistered. The registry is heavily used to provide access to system services: a newly started Erlang node already contains 13 registered processes. However, the built-in process registry imposes several, sometimes unwelcome, limitations: registered names are restricted to be atoms, the same process cannot be registered with multiple names, and there is no efficient way to search the registry (other than by name lookup). This motivated Ulf Wiger (who was working for Ericsson at the time) to develop an extended process registry in Erlang, which could be modified and extended much more easily than the one in the virtual machine. Wiger’s process registry software has been in use in Ericsson products for several years (Wiger 2007). In our case study we consider an earlier prototype of this software, called proc_reg, incorporating an optimization that proved not to work. The API supported is just: reg(Name,Pid) to register a pid, where(Name) to look up a pid, unreg(Name) to remove a registration, and send(Name,Msg) to send a message to a registered process. Like the production code, proc_reg stores the association between names and pids in Erlang Term Storage (“ETS tables”)—hash tables, managed by the virtual machine, that hold a set of tuples and support tuple-lookup using the first component as a key (cf. Armstrong 2007, chap 15). It also creates a monitor for each registered process, whose effect is to send proc_reg a “DOWN” message if the registered process crashes, so it can be removed from the registry. Two ETS table entries are created for each registration: a “forward” entry that maps names to pids, and a “reverse” entry that maps registered pids to the monitor reference. The monitor reference is needed to turn off monitoring again, if the process should later be unregistered. Also like the production code, proc_reg is implemented as a server process using Erlang’s generic server library (cf. Armstrong 2007, chap 16). This library provides a robust way to build clientserver systems, in which clients make “synchronous calls” to the server by sending a call message, and awaiting a matching reply1 . Each operation—reg, where, unreg and send—is supported by a different call message. The operations are actually executed by the server, one at a time, and so no race conditions can arise. At least, this is the theory. In practice there is a small cost to the generic server approach: each request sends two messages and requires two context switches, and although these are cheap in Erlang, they are not free, and turn out to be a bottleneck in system start-up times, for example. The prototype proc_reg attempts to optimize this, by moving the creation of the first “forward” ETS table entry into the clients. If this succeeds (because there is no previous entry with that name), then clients just make an “asynchronous” call to the server (a so-called cast message, with no reply) to inform it that it should complete the registration later. This avoids a context switch, and reduces two messages to one. If there is already a registered process with the same name, then the reg operation fails (with an exception)—unless, of course, the process is dead. In this case, the process will soon be removed from the registry by the server; clients ask the server to “audit” the dead process to hurry this along, then complete their registration as before.
This prototype was one of the first pieces of software to be tested using QuickCheck at Ericsson. At the time, in late 2005, it was believed to work, and indeed was accompanied by quite an extensive suite of unit tests—including cases designed specifically to test for race conditions. We used QuickCheck to generate and run random sequences of API calls in two concurrent processes, and instrumented the proc_reg code with calls to yield() (which cedes control to the scheduler) to cause fine-grain interleaving of concurrent operations. By so doing, we could show that proc_reg was incorrect, since our tests failed. But the failing test cases we found were large, complex, and very hard to understand, and we were unable to use them to diagnose the problem. As a result, this version of proc_reg was abandoned, and development of the production version continued without the optimization. While we were pleased that QuickCheck was able to reveal bugs in proc_reg, we were unsatisfied that it could not help us to find them. Moreover, the QuickCheck property we used to test it was hard-to-define and ad hoc—and not easily reusable to test any other software. This paper is the story of how we addressed these problems—and returned to apply our new methods successfully to the example that defeated us before.
3.
An Overview of Quviq QuickCheck
QuickCheck (Claessen and Hughes 2000) is a tool that tests universally quantified properties, instead of single test cases. QuickCheck generates random test cases from each property, tests whether the property is true in that case, and reports cases for which the property fails. Recent versions also “shrink” failing test cases automatically, by searching for similar, but smaller test cases that also fail. The result of shrinking is a “minimal”2 failing case, which often makes the root cause of the problem very easy to find. Quviq QuickCheck is a commercial version that includes support for model-based testing using a state machine model (Hughes 2007). This means that it has standard support for generating sequences of API calls using this state machine model. It has been used to test a wide variety of industrial software, such as Ericsson’s Media Proxy (Arts et al. 2006) among others. State machine models are tested using an additional library, eqc_statem, which invokes call-backs supplied by the user to generate and test random, wellformed sequences of calls to the software under test. We illustrate eqc_statem by giving fragments of a (sequential) specification of proc_reg. Let us begin with an example of a generated test case (a sequence of API calls). [{set,{var,1},{call,proc_reg_eqc,spawn,[]}}, {set,{var,2},{call,proc_reg,where,[c]}}, {set,{var,3},{call,proc_reg_eqc,spawn,[]}}, {set,{var,4},{call,proc_reg_eqc,kill,[{var,1}]}}, {set,{var,5},{call,proc_reg,where,[d]}}, {set,{var,6},{call,proc_reg_eqc,reg,[a,{var,1}]}}, {set,{var,7},{call,proc_reg_eqc,spawn,[]}}]
eqc_statem test cases are lists of symbolic commands represented by Erlang terms, each of which binds a symbolic variable (such as {var,1}) to the result of a function call, where {call,M,F,Args} represents a call of function F in module M with arguments Args3 . Note that previously bound variables can be used in later calls. Test cases for proc_reg in particular randomly spawn processes (to use as test data), kill them (to simulate crashes at random times), or pass them to proc_reg operations. Here proc_reg_eqc is the module containing the specification of proc_reg, in which we define local 2 In
the sense that it cannot shrink to a failing test with the shrinking algorithm used. 3 In Erlang, variables start with an uppercase character, whereas atoms (constants) start with a lowercase character.
1 Unique
identifiers are generated for each call, and returned in the reply, so that no message confusion can occur.
150
versions of reg and unreg which just call proc_reg and catch any exceptions. This allows us to write properties that test whether an exception is raised correctly or not. (An uncaught exception in a test is interpreted as a failure of the entire test). We model the state of a test case as a list of processes spawned, processes killed, and the {Name,Pid} pairs currently in the registry. We normally encapsulate the state in a record:
postcondition(S,{call,_,reg,[Name,Pid]},Res) -> case Res of true -> register_ok(S,Name,Pid); {’EXIT’,_} -> not register_ok(S,Name,Pid) end; postcondition(S,{call,_,unreg,[Name]},Res) -> case Res of true -> unregister_ok(S,Name); {’EXIT’,_} -> not unregister_ok(S,Name) end; postcondition(S,{call,_,where,[Name]},Res) -> lists:member({Name,Res},S#state.regs); postcondition(_S,{call,_,_,_},_Res) -> true.
-record(state,{pids=[],regs=[],killed=[]}).
eqc_statem generates random calls using the call-back function command that we supply as part of the state machine model, with the test case state as its argument: command(S) -> oneof( [{call,?MODULE,spawn,[]}] ++ [{call,?MODULE,kill, [elements(S#state.pids)]} || S#state.pids/=[]] ++ [{call,?MODULE,reg,[name(),elements(S#state.pids)]} || S#state.pids/=[]] ++ [{call,?MODULE,unreg,[name()]}] ++ [{call,proc_reg,where,[name()]}]).
unregister_ok(S,Name) -> lists:keymember(Name,1,S#state.regs).
Note that reg(Name,Pid) and unreg(Name) are required to return exceptions if Name is already used/not used respectively, but that reg always returns true if Pid is dead, even though no registration is performed! This may perhaps seem a surprising design decision, but it is consistent. As a comparison, the built-in process registry sometimes returns true and sometimes raises an exception when registering dead processes. This is due to the fact that a context switch is required to clean up. State machine models can also specify a precondition for each call, which restricts test cases to those in which all preconditions hold. In this example, we could have used preconditions to exclude test cases that we expect to raise exceptions—but we prefer to allow any test case, and check that exceptions are raised correctly, so we define all preconditions to be true. With these four call-backs, plus another call-back specifying the initial state, our specification is almost complete. It only remains to define the top-level property which generates and runs tests:
name() -> elements([a,b,c,d]).
The function oneof is a QuickCheck generator that randomly uses one element from a list of generators; in this case, the list of candidates to choose from depends on the test case state. ([X||P] is a degenerate list comprehension, that evaluates to the empty list if P is false, and [X] if P is true—so reg and kill can be generated only if there are pids available to pass to them.) We decided not to include send in test cases, because its implementation is quite trivial. The macro ?MODULE expands to the name of the module that it appears in, proc_reg_eqc in this case. The next_state function specifies how each call is supposed to change the state: next_state(S,V,{call,_,spawn,_}) -> S#state{pids=[V|S#state.pids]}; next_state(S,V,{call,_,kill,[Pid]}) -> S#state{killed=[Pid|S#state.killed], regs=[{Name,P} || {Name,P} case register_ok(S,Name,Pid) andalso not lists:member(Pid,S#state.killed) of true -> S#state{regs=[{Name,Pid}|S#state.regs]}; false -> S end; next_state(S,_V,{call,_,unreg,[Name]}) -> S#state{regs=lists:keydelete(Name,1,S#state.regs)}; next_state(S,_V,{call,_,where,[_]}) -> S.
prop_proc_reg() -> ?FORALL(Cmds,commands(?MODULE), begin {ok,ETSTabs} = proc_reg_tabs:start_link(), {ok,Server} = proc_reg:start_link(), {H,S,Res} = run_commands(?MODULE,Cmds), cleanup(ETSTabs,Server), Res == ok end).
Here ?FORALL binds Cmds to a random list of commands generated by commands, then we initialize the registry, run the commands, clean up, and check that the result of the run (Res) was a success. Here commands and run_commands are provided by eqc_statem, and take the current module name as an argument in order to find the right call-backs. The other components of run_commands’ result, H and S, record information about the test run, and are of interest primarily when a test fails. This is not the case here: sequential testing of proc_reg does not fail.
register_ok(S,Name,Pid) -> not lists:keymember(Name,1,S#state.regs).
Note that the new state can depend on the result of the call (the second argument V), as in the first clause above. Note also that killing a process removes it from the registry (in the model), and that registering a dead process, or a name that is already registered (see register_ok), should not change the registry state. We do allow the same pid to be registered with several names, however. When running tests, eqc_statem checks the postcondition of each call, specified via another call-back that is given the state before the call, and the actual result returned, as arguments. Since we catch exceptions in each call, which converts them into values of the form {’EXIT’,Reason}, our proc_reg postconditions can test that exceptions are raised under precisely the right circumstances:
4.
Parallel Testing with QuickCheck
4.1
A Parallel Correctness Criterion
In order to test for race conditions, we need to generate test cases that are executed in parallel, and we also need a specification of the correct parallel behavior. We have chosen, in this paper, to use a specification that just says that the API operations we are testing should behave atomically. How can we tell from test results whether or not each operation “behaved atomically”? Following Lamport (1979) and Herlihy and
151
in the worst case). Finally, we run tests by first running the prefix, then spawning two processes to run the two command-lists in parallel, and collecting their results, which will be non-deterministic depending on the actual parallel scheduling of events. We decide whether a test has passed, by attempting to construct a sequentialization of the test case which explains the results observed. We begin with the sequential prefix of the test case, and use the next_state function of the eqc_statem model to compute the test case state after this prefix is completed. Then we try to extend the sequential prefix, one command at a time, by choosing the first command from one of the parallel branches, and moving it into the prefix. This is allowed only if the postcondition specified in the eqc_statem model accepts the actual result returned by the command when we ran the test. If so, we use the next_state function to compute the state after this command, and continue. If the first commands of both branches fulfilled their postconditions, then we cannot yet determine which command took effect first, and we must explore both possibilities further. If we succeed in moving all commands from the parallel branches into the sequential prefix, such that all postconditions are satisfied, then we have found a possible sequentialization of the test case explaining the results we observed. If our search fails, then there is no such sequence, and the test failed. This is a greedy algorithm: as soon as a postcondition fails, then we can discard all potential sequentializations with the failing command as the next one in the sequence. This happens often enough to make the search reasonably fast in practice. As a further optimization, we memoize the search function on the remaining parallel branches and the test case state. This is useful, for example, when searching for a sequentialization of [A, B] and [C, D], if both [A, C] and [C, A] are possible prefixes, and they lead to the same test state—for then we need only try to sequentialize [B] and [D] once. We memoize the non-interference test in a similar way, and these optimizations give an appreciable, though not dramatic, speed-up in our experiments—of about 20%. With these optimizations, generating and running parallel tests is acceptably fast.
Wing (1987), we consider a test to have passed if the observed results are the same as some possible sequential execution of the operations in the test—that is, a possible interleaving of the parallel processes in the test. Of course, testing for atomic behavior is just a special case, and in general we may need to test other properties of concurrent code too—but we believe that this is a very important special case. Indeed, Herlihy and Wing claim that their notion of linearizability “focuses exclusively on a subset of concurrent computations that we believe to be the most interesting and useful”; we agree. In particular, atomicity is of great interest for the process registry. One great advantage of this approach is that we can reuse the same specification of the sequential behavior of an API, to test its behavior when invocations take place in parallel. We need only find the right linearization of the API calls in the test, and then use the sequential specification to determine whether or not the test has passed. We have implemented this idea in a new QuickCheck module, eqc_par_statem, which takes the same state-machine specifications as eqc_statem, but tests the API in parallel instead. While state machine specifications require some investment to produce in real situations, this means that we can test for race conditions with no further investment in developing a parallel specification. It also means that, as the code under test evolves, we can switch freely toand-fro between sequential testing to ensure the basic behavior still works, and race condition testing using eqc_par_statem. The difficulty with this approach is that, when we run a test, then there is no way to observe the sequence in which the API operations take effect. (For example, a server is under no obligation to service requests in the order in which they are made, so observing this order would tell us nothing.) In general, the only way to tell whether there is a possible sequentialization of a test case which can explain the observed test results, is to enumerate all possible sequentializations. This is prohibitively expensive unless care is taken when test cases are generated. 4.2
Generating Parallel Test Cases
Our first approach to parallel test case generation was to use the standard Quviq QuickCheck library eqc_statem to generate sequential test cases, then execute all the calls in the test case in parallel, constrained only by the data dependencies between them (which arise from symbolic variables, bound in one command, being used in a later one). This generates a great deal of parallelism, but sadly also an enormous number of possible serializations—in the worst case in which there are no data dependencies, a sequence of n commands generates n! possible serializations. It is not practically feasible to implement a test oracle for parallel tests of this sort. Instead, we decided to generate parallel test cases of a more restricted form. They consist of an initial sequential prefix, to put the system under test into a random state, followed by exactly two sequences of calls which are performed in parallel. Thus the possible serializations consist of the initial prefix, followed by an interleaving of the two parallel sequences. (Lu et al. (2008) gives clear evidence that it is possible to discover a large fraction of the concurrency related bugs by using only two parallel threads/processes.) We generate parallel test cases by parallelizing a suffix of an eqc_statem test case, separating it into two lists of commands of roughly equal length, with no mutual data dependencies, which are non-interfering according to the sequential specification. By non-interference, we mean that all command preconditions are satisfied in any interleaving of the two lists, which is necessary to prevent tests from failing because a precondition was unsatisfied—not an interesting failure. We avoid parallelizing too long a suffix (longer than 16 commands), to keep the number of possible interleavings feasible to enumerate (about 10,000
4.3
Shrinking Parallel Test Cases
When a test fails, QuickCheck attempts to shrink the failing test by searching for a similar, but smaller test case which also fails. QuickCheck can often report minimal failing examples, which is a great help in fault diagnosis. eqc_statem already has built-in shrinking methods, of which the most important tries to delete unnecessary commands from the test case, and eqc_par_statem inherits these methods. But we also implement an additional shrinking method for parallel test cases: if it is possible to move a command from one of the parallel suffixes into the sequential prefix, then we do so. Thus the minimal test cases we find are “minimally parallel”—we know that the parallel branches in the failing tests reported really do race, because everything that can be made sequential, is sequential. This also assists fault diagnosis. 4.4 Testing proc reg for Race Conditions To test the process registry using eqc_par_statem, it is only necessary to modify the property in Section 2 to use eqc_par_statem rather than eqc_statem to generate and run test cases. prop_proc_reg_parallel() -> ?FORALL(Cmds,eqc_par_statem:commands(?MODULE), begin {ok,ETSTabs} = proc_reg_tabs:start_link(), {ok,Server} = proc_reg:start_link(), {H,{A,B},Res} = eqc_par_statem:run_commands(?MODULE,Cmds), cleanup(ETSTabs,Server), Res == ok end).
152
5.
The type returned by run_commands is slightly different (A and B are lists of the calls made in each parallel branch, paired with the results returned), but otherwise no change to the property is needed. When this property is tested on a single-core processor, all tests pass. However, as soon as it is tested on a dual-core, tests begin to fail. Interestingly, just running on two cores gives us enough finegrain interleaving of concurrent processes to demonstrate the presence of race conditions, something we had to achieve by instrumenting the code with calls to yield() to control the scheduler when we first tested this code in 2005. However, just as in 2005, the reported failing test cases are large, and do not shrink to small examples. This makes the race condition very hard indeed to diagnose. The problem is that the test outcome is not determined solely by the test case: depending on the actual interleaving of memory operations on the dual core, the same test may sometimes pass and sometimes fail. This is devastating for QuickCheck’s shrinking, which works by repeatedly replacing the failed test case by a smaller one which still fails. If the smaller test happens to succeed—by sheer chance, as a result of non-deterministic execution—then the shrinking process stops. This leads QuickCheck to report failed tests which are far from minimal. Our solution to this is almost embarrassing in its simplicity: instead of running each test only once, we run it many times, and consider a test case to have passed only if it passes every time we run it. We express this concisely using a new form of QuickCheck property, ?ALWAYS(N,Prop), which passes if Prop passes N times in a row4 . Now, provided the race condition we are looking for is reasonably likely to be provoked by test cases in which it is present, then ?ALWAYS(10,...) is very likely to provoke it—and so tests are unlikely to succeed “by chance” during the shrinking process. This dramatically improves the effectiveness of shrinking, even for quite small values of N. While we do not always obtain minimal failing tests with this approach, we find we can usually obtain a minimal example by running QuickCheck a few times. When testing the proc_reg property above, we find the following simple counterexample:
A User-level Scheduler
At this point, we have found a simple test case that fails, but we do not know why it failed—we need to debug it. A natural next step would be to turn on Erlang’s tracing features and rerun the test. But when the bug is caused by a race condition, then turning on tracing is likely to change the timing properties of the code, and thus interfere with the test failure! Even simply repeating the test may lead to a different result, because of the non-determinism inherent in running on a multi-core. This is devastating for debugging. What we need is to be able to repeat the test as many times as we like, with deterministic results, and to observe what happens during the test, so that we can analyze how the race condition was provoked. With this in mind, we have implemented a new Erlang module that can control the execution of designated Erlang processes and records a trace of all relevant events. Our module can be thought of as a user-level scheduler, sitting on top of the normal Erlang scheduler. Its aim is to take control over all sources of non-determinism in Erlang programs, and instead take those scheduling decisions randomly. This means that we can repeat a test using exactly the same schedule by supplying the same random number seed: this makes tests repeatable. We have named the module PULSE, short for ProTest User-Level Scheduler for Erlang. The Erlang virtual machine (VM) runs processes for relatively long time-slices, in order to minimize the time spent on context switching—but as a result, it is very unlikely to provoke race conditions in small tests. It is possible to tune the VM to perform more context switches, but even then the scheduling decisions are entierly deterministic. This is one reason why tricky concurrency bugs are rarely found during unit testing; it is not until later stages of a project, when many components are tested together, that the standard scheduler begins to preempt processes and trigger race conditions. In the worst case, bugs don’t appear until the system is put under heavy load in production! In these later stages, such errors are expensive to debug. One other advantage (apart from repeatability) of PULSE is that it generates much more fine-grain interleaving than the built-in scheduler in the Erlang virtual machine (VM), because it randomly chooses the next process to run at each point. Therefore, we can provoke race conditions even in very small tests. Erlang’s scheduler is built into its virtual machine—and we did not want to modify the virtual machine itself. Not only would this be difficult—it is a low-level, fairly large and complex C program—but we would need to repeat the modifications every time a new version of the virtual machine was released. We decided, therefore, to implement PULSE in Erlang, as a user-level scheduler, and to instrument the code of the processes that it controls so that they cooperate with it. As a consequence, PULSE can even be used in conjunction with legacy or customized versions of the Erlang VM (which are used in some products). The user level scheduler also allows us to restrict our debugging effort to a few processes, whereas we are guaranteed that the rest of the processes are executed normally.
{[{set,{var,5},{call,proc_reg_eqc,spawn,[]}}, {set,{var,9},{call,proc_reg_eqc,kill,[{var,5}]}}, {set,{var,15},{call,proc_reg_eqc,reg,[a,{var,5}]}}], {[{set,{var,19},{call,proc_reg_eqc,reg,[a,{var,5}]}}], [{set,{var,18},{call,proc_reg_eqc,reg,[a,{var,5}]}}]}}
This test case first creates and kills a process, then tries to register it (which should have no effect, because it is already dead), and finally tries to register it again twice, in parallel. Printing the diagnostic output from run_commands, we see: Sequential: [{{state,[],[],[]},}, {{state,[],[],[]},ok}, {{state,[],[],[]},true}] Parallel: {[{{call,proc_reg_eqc,reg,[a,]}, {’EXIT’,{badarg,[{proc_reg,reg,2},...]}}}], [{{call,proc_reg_eqc,reg,[a,]},true}]} Res: no_possible_interleaving
5.1
Overall Design
The central idea behind developing PULSE was to provide absolute control over the order of relevant events. The first natural question that arises is: What are the relevant events? We define a side-effect to be any interaction of a process with its environment. Of particular interest in Erlang is the way processes interact by message passing, which is asynchronous. Message channels, containing messages that have been sent but not yet delivered, are thus part of the environment and explicitly modelled as such in PULSE. It makes sense to separate side-effects into two kinds: outward side-effects, that influence only the environment (such as sending a message over a channel, which does not block and cannot fail, or printing a message), and inward side-effects, that allow the environment to
(where the ellipses replace an uninteresting stack trace). The values displayed under “Parallel:” are the results A and B from the two parallel branches—they reveal that one of the parallel calls to reg raised an exception, even though trying to register a dead process should always just return true! How this happened, though, is still quite mysterious—but will be explained in the following sections. 4 In
PULSE :
fact we need only repeat tests during shrinking.
153
PULSE needs to be able to control the order in which those interactions take place. Since we are not interested in controlling the order in which pure functions are called we allow the programmer to specify which external functions have side-effects. Each call of a side-effecting function is then instrumented with code that yields before performing the real call and PULSE is free to run another process at that point. Side-effecting functions are treated as atomic which is also an important feature that aids in testing systems built of multiple components. Once we establish that a component contains no race conditions we can remove the instrumentation from it and mark its operations as atomic side-effects. We will then be able to test other components that use it and each operation marked as sideeffecting will show up as a single event in a trace. Therefore, it is possible to test a component for race conditions independently of the components that it relies on.
influence the behavior of the process (such as receiving a message from a channel, or asking for the system time). We do not want to take control over purely functional code, or side-effecting code that only influences processes locally. PULSE takes control over some basic features of the Erlang RTS (such as spawning processes, message sending, linking, etc.), but it knows very little about standard library functions – it would be too much work to deal with each of these separately! Therefore, the user of PULSE can specify which library functions should be dealt with as (inward) side-effecting functions, and PULSE has a generic way of dealing with these (see subsection 5.3). A process is only under the control of PULSE if its code has been properly instrumented. All other processes run as normal. In instrumentation, occurrences of side-effecting actions are replaced by indirections that communicate with PULSE instead. In particular, outward side-effects (such as sending a message to another process) are replaced by simply sending a message to PULSE with the details of the side-effect, and inward side-effects (such as receiving a message) are replaced by sending a request to PULSE for performing that side-effect, and subsequently waiting for permission. To ease the instrumentation process, we provide an automatic instrumenter, described in subsection 5.4. 5.2
5.4
Inner Workings
The PULSE scheduler controls its processes by allowing only one of them to run at a time. It employs a cooperative scheduling method: At each decision point, PULSE randomly picks one of its waiting processes to proceed, and wakes it up. The process may now perform a number of outward side-effects, which are all recorded and taken care of by PULSE, until the process wants to perform an inward side-effect. At this point, the process is put back into the set of waiting processes, and a new decision point is reached. The (multi-node) Erlang semantics (Svensson and Fredlund 2007) provides only one guarantee for message delivery order: that messages between a pair of processes arrive in the same order as they were sent. So as to adhere to this, PULSE’s state also maintains a message queue between each pair of processes. When process P performs an outward side-effect by sending a message M to the process Q, then M is added to the queue hP, Qi. When PULSE wants to wake up a waiting process Q, it does so by randomly picking a non-empty queue hP 0 , Qi with Q as its destination, and delivering the first message in that queue to Q. Special care needs to be taken for the Erlang construct receive . . . after n -> . . . end, which allows a receiving process to only wait for an incoming message for n milliseconds before continuing, but the details of this are beyond the scope of this paper. As an additional benefit, this design allows PULSE to detect deadlocks when it sees that all processes are blocked, and there exist no message queues with the blocked processes as destination. As a clarification, the message queues maintained by PULSE for each pair of processes should not be confused with the internal mailbox that each process in Erlang has. In our model, sending a message M from P to Q goes in four steps: (1) P asynchronously sends off M , (2) M is on its way to Q, (3) M is delivered to Q’s mailbox, (4) Q performs a receive statement and M is selected and removed from the mailbox. The only two events in this process that we consider side-effects are (1) P sending of M , and (3) delivering M to Q’s mailbox. In what order a process decides to process the messages in its mailbox is not considered a side-effect, because no interaction with the environment takes place. 5.3
Instrumentation
The program under test has to cooperate with PULSE, and the relevant processes should use PULSE’s API to send and receive messages, spawn processes, etc., instead of Erlang’s built-in functionality. Manually altering an Erlang program so that it does this is tedious and error-prone, so we developed an instrumenting compiler that does this automatically. The instrumenter is used in exactly the same way as the normal compiler, which makes it easy to switch between PULSE and the normal Erlang scheduler. It’s possible to instrument and load a module at runtime by typing in a single command at the Erlang shell. Let us show the instrumentation of the four most important constructs: sending a message, yielding, spawning a process, and receiving a message. 5.4.1 Sending If a process wants to send a message, the instrumenter will redirect this as a request to the PULSE scheduler. Thus, Pid ! Msg is replaced by scheduler ! {send, Pid, Msg}, Msg
The result value of sending a message is always the message that was sent. Since we want the instrumented send to yield the same result value as the original one, we add the second line. 5.4.2
Yielding
A process yields when it wants to give up control to the scheduler. Yields are also introduced just before each user-specified sideeffecting external function. After instrumentation, a yielding process will instead give up control to PULSE. This is done by telling it that the process yields, and waiting for permission to continue. Thus, yield() is replaced by scheduler ! yield, receive {scheduler, go} -> ok end
In other words, the process notifies PULSE and then waits for the message go from the scheduler before it continues. All control messages sent by PULSE to the controlled processes are tagged with {scheduler, _} in order to avoid mixing them up with ”real” messages. 5.4.3 Spawning A process P spawning a process Q is considered an outward sideeffect for P , and thus P does not have to block. However, PULSE must be informed of the existence of the new process Q, and Q
External Side-effects
In addition to sending and receiving messages between themselves, the processes under test can also interact with uninstrumented code.
154
needs to be brought under its control. The spawned process Q must therefore wait for PULSE to allow it to run. Thus, spawn(Fun) is replaced by
SRes = scheduler:start([{seed,Seed}], fun() -> {ok,ETSTabs} = proc_reg_tabs:start_link(), {ok,Server} = proc_reg:start_link(), eqc_par_statem:run_commands(?MODULE,Cmds), cleanup(ETSTabs,Server), end), {H,AB,Res} = scheduler:get_result(SRes), Res == ok end))).
Pid = spawn(fun() -> receive {scheduler, go} -> Fun() end end), scheduler ! {spawned, Pid}, Pid
In other words, the process spawns an altered process that waits for the message go from the scheduler before it does anything. The scheduler is then informed of the existence of the spawned process, and we continue. 5.4.4
PULSE uses a random seed, generated by seed(). It also takes a function as an argument, so we create a lambda-function which initializes and runs the tests. The result of running the scheduler is a list of things, thus we need to call scheduler:get result to retrieve the actual result from run commands. We should also remember to instrument rather than compile all the involved modules. Note that we still use ?ALWAYS in order to run the same test data with different random seeds, which helps the shrinking process in finding smaller failing test cases that would otherwise be less likely to fail. When testing this modified property, we find the following counterexample, which is in fact simpler than the one we found in Section 4.4:
Receiving
Receiving in Erlang works by pattern matching on the messages in the process’ mailbox. When a process is ready to receive a new message, it will have to ask PULSE for permission. However, it is possible that an appropriate message already exists in its mailbox, and receiving this message would not be a side-effect. Therefore, an instrumented process will first check if it is possible to receive a message with the desired pattern, and proceed if this is possible. If not, it will tell the scheduler that it expects a new message in its mailbox, and blocks. When woken up again on the delivery of a new message, this whole process is repeated if necessary. We need a helper function that implements this checkingwaiting-checking loop. It is called receiving:
{[{set,{var,9},{call,proc_reg_eqc,spawn,[]}}, {set,{var,10},{call,proc_reg_eqc,kill,[{var,9}]}}], {[{set,{var,15},{call,proc_reg_eqc,reg,[c,{var,9}]}}], [{set,{var,12},{call,proc_reg_eqc,reg,[c,{var,9}]}}]}}
When prompted, PULSE provides quite a lot of information about the test case run and the scheduling decisions taken. Below we show an example of such information. However, it is still not easy to explain the counterexample; in the next section we present a method that makes it easier to understand the scheduler output.
receiving(Receiver) -> Receiver(fun() -> scheduler ! block, receive {scheduler, go} -> receiving(Receiver) end end).
-> calls scheduler:process_flag [priority,high] returning normal. -> sends ’{call,{attach,}, ,#Ref}’ to . -> blocks. *** unblocking by delivering ’{call,{attach,}, , #Ref}’ sent by . ...
receiving gets a receiver function as an argument. A receiver function is a function that checks if a certain message is in its mailbox, and if not, executes its argument function. The function receiving turns this into a loop that only terminates once PULSE has delivered the right message. When the receiver function fails, PULSE is notified by the block message, and the process waits for permission to try again. Code of the form receive Pat -> Exp end
is then replaced by receiving(fun (Failed) -> receive Pat -> Exp after 0 -> Failed() end end)
6.
Visualizing Traces
records a complete trace of the interesting events during test execution, but these traces are long, and tedious to understand. To help us interpret them, we have, utilizing the popular GraphViz package (Gansner and North 1999), built a trace visualizer that draws the trace as a graph. For example, Figure 1 shows the graph drawn for one possible trace of the following program: PULSE
In the above, we use the standard Erlang idiom (receive . . . after 0 -> . . . end) for checking if a message of a certain type exists. It is easy to see how receive statements with more than one pattern can be adapted to work with the above scheme.
procA() -> PidB = spawn(fun procB/0), PidB ! a, process_flag(trap_exit, true), link(PidB), receive {’EXIT’,_,Why} -> Why end.
5.5 Testing proc reg with PULSE To test the proc reg module using both QuickCheck and PULSE, we need to make a few modifications to the QuickCheck property in Section 4.4. prop_proc_reg_scheduled() -> ?FORALL(Cmds,eqc_par_statem:commands(?MODULE), ?ALWAYS(10,?FORALL(Seed,seed(), begin
procB() -> receive a -> exit(kill) end.
155
root
root scheduler:process_flag(trap_exit,true) = false
a
procA.PidB
scheduler:process_flag(trap_exit,true) = false
a
link link procA.PidB {EXIT,_,noproc}
kill
{EXIT,_,kill} kill
noproc
kill
Figure 1. A simple trace visualization.
Figure 2. An alternative possible execution.
The function procA starts by spawning a process, and subsequently sends it a message a. Later, procA links to the process it spawned, which means that it will get notified when that process dies. The default behavior of a process when such a notification happens is to also die (in this way, one can build hierarchies of processes). Setting the process flag trap exit to true changes this behaviour, and the notification is delivered as a regular message of the form {EXIT,_,_} instead. In the figure, each process is drawn as a sequence of state transitions, from a start state drawn as a triangle, to a final state drawn as an inverted triangle, all enclosed in a box and assigned a unique color. (Since the printed version of the diagrams may lack these colors, we reference diagram details by location and not by color. However, the diagrams are even more clear in color.) The diagram shows the two processes, procA (called root) which is shown to the left (in red), and procB (called procA.PidB, a name automatically derived by PULSE from the point at which it was spawned) shown to the right (in blue). Message delivery is shown by gray arrows, as is the return of a result by the root process. As explained in the previous section, processes make transitions when receiving a message5 , or when calling a function that the instrumenter knows has a side-effect. From the figure, we can see that the root process spawned PidB and sent the message a to it, but before the message was delivered then the root process managed to set its trap_exit process flag, and linked to PidB. PidB then received its message, and killed itself, terminating with reason kill. A message was sent back to root, which then returned the exit reason as its result. Figure 2 shows an alternative trace, in which PidB dies before root creates a link to it, which generates an exit message with a different exit reason. The existence of these two different traces indicates a race condition when using spawn and link separately (which is the reason for the existence of an atomic spawn_link function in Erlang). The diagrams help us to understand traces by gathering together all the events that affect one process into one box; in the original traces, these events may be scattered throughout the entire trace. But notice that the diagrams also abstract away from irrelevant information—specifically, the order in which messages are deliv-
root file:write_file("a.txt",_) = ok
write_race.Pid
ok
file:write_file("a.txt",_) = ok
Figure 3. A race between two side-effects.
ered to different processes, which is insignificant in Erlang. This abstraction is one strong reason why the diagrams are easier to understand than the traces they are generated from. However, we do need to know the order in which calls to functions with side-effects occur, even if they are made in different processes. To make this order visible, we add dotted black arrows to our diagrams, from one side-effecting call to the next. Figure 3 illustrates one possible execution of this program, in which two processes race to write to the same file: write_race() -> Pid = spawn(fun() -> file:write_file("a.txt","a") end), file:write_file("a.txt","b").
In this diagram, we can see that the write_file in the root process preceded the one in the spawned process write_race.Pid. If we draw these arrows between every side-effect and its successor, then our diagrams rapidly become very cluttered. However,
5 If
messages are consumed from a process mailbox out-of-order, then we show the delivery of a message to the mailbox, and its later consumption, as separate transitions.
156
it is only necessary to indicate the sequencing of side-effects explicitly if their sequence is not already determined. For each pair of successive side-effect transitions, we thus compute Lamport’s “happens before” relation (Lamport 1978) between them, and if this already implies that the first precedes the second, then we draw no arrow in the diagram. Interestingly, in our examples then this eliminates the majority of such arrows, and those that remain tend to surround possible race conditions—where the message passing (synchronization) does not enforce a particular order of sideeffects. Thus black dotted arrows are often a sign of trouble.
message overtaking even on a single “many-core” processor; thus we consider it an advantage that our scheduler allows this behavior, and can provoke race conditions that it causes. It should be noted that exactly the same scenario can be triggered in an alternative way (without parallel processes and multicore!); namely if the BPid above is preempted between its call to ets:insert new and sending the cast-message. However, the likelihood for this is almost negligible, since the Erlang scheduler prefers running processes for relatively long time-slices. Using PULSE does not help triggering the scenario in this way either. PULSE is not in control at any point between ets:insert new and sending the cast-message, meaning that only the Erlang scheduler controls the execution. Therefore, the only feasible way to repeatedly trigger this faulty scenario is by delaying the cast-message by using PULSE (or a similar tool).
6.1 Analyzing the proc reg race conditions Interestingly, as we saw in Section 5.5, when we instrumented proc_reg and tested it using PULSE and QuickCheck, we obtained a different—even simpler—minimal failing test case, than the one we had previously discovered using QuickCheck with the built-in Erlang scheduler. Since we need to use PULSE in order to obtain a trace to analyze, then we must fix this bug first, and see whether that also fixes the first problem we discovered. The failing test we find using PULSE is this one:
6.2 A second race condition in proc reg Having corrected the bug in proc reg we repeated the QuickCheck test. The property still fails, with the same minimal failing case that we first discovered (which is not so surprising since the problem that we fixed in the previous section cannot actually occur with today’s VM). However, we were now able to reproduce the failure with PULSE, as well as the built-in scheduler. As a result, we could now analyze and debug the race condition. The failing case is:
{[{set,{var,9},{call,proc_reg_eqc,spawn,[]}}, {set,{var,10},{call,proc_reg_eqc,kill,[{var,9}]}}], {[{set,{var,15},{call,proc_reg_eqc,reg,[c,{var,9}]}}], [{set,{var,12},{call,proc_reg_eqc,reg,[c,{var,9}]}}]}}
{[{set,{var,4},{call,proc_reg_eqc,spawn,[]}}, {set,{var,7},{call,proc_reg_eqc,kill,[{var,4}]}}, {set,{var,12},{call,proc_reg_eqc,reg,[b,{var,4}]}}], {[{set,{var,18},{call,proc_reg_eqc,reg,[b,{var,4}]}}], [{set,{var,21},{call,proc_reg_eqc,reg,[b,{var,4}]}}]}}
In this test case, we simply create a dead process (by spawning a process and then immediately killing it), and try to register it twice in parallel, and as it happens the first call to reg raises an exception. The diagram we generate is too large to include in full, but in Figure 4 we reproduce the part showing the problem. In this diagram fragment, the processes are, from left to right, the proc_reg server, the second parallel fork (BPid), and the first parallel fork (APid). We can see that BPid first inserted its argument into the ETS table, recording that the name c is now taken, then sent an asynchronous message to the server ({cast,{..}}) to inform it of the new entry. Thereafter APid tried to insert an ETS entry with the same name—but failed. After discovering that the process being registered is actually dead, APid sent a message to the server asking it to “audit” its entry ({call,{..},_,_})—that is, clean up the table by deleting the entry for a dead process. But this message was delivered before the message from BPid! As a result, the server could not find the dead process in its table, and failed to delete the entry created by BPid, leading APid’s second attempt to create an ETS entry to fail also—which is not expected to happen. When BPid’s message is finally received and processed by the server, it is already too late. The problem arises because, while the clients create “forward” ETS entries linking the registered name to a pid, it is the server which creates a “reverse” entry linking the pid to its monitoring reference (created by the server). It is this reverse entry that is used by the server when asked to remove a dead process from its tables. We corrected the bug by letting clients (atomically) insert two ETS entries into the same table: the usual forward entry, and a dummy reverse entry (lacking a monitoring reference) that is later overwritten by the server. This dummy reverse entry enables the server to find and delete both entries in the test case above, thus solving the problem. In fact, the current Erlang virtual machine happens to deliver messages to local mailboxes instantaneously, which means that one message cannot actually overtake another message sent earlier— the cause of the problem in this case. This is why this minimal failing test was not discovered when we ran tests on a multi-core, using the built-in scheduler. However, this behavior is not guaranteed by the language definition, and indeed, messages between nodes in a distributed system can overtake each other in this way. It is expected that future versions of the virtual machine may allow
In this test case we also create a dead process, but we try to register it once in the sequential prefix, before trying to register it twice in parallel. Once again, one of the calls to reg in the parallel branches raised an exception. Turning again to the generated diagram, which is not included in the paper for space reasons, we observed that both parallel branches (APid and BPid) fail to insert b into the ETS table. They fail since the name b was already registered in the sequential part of the test case, and the server has not yet processed the DOWN message generated by the monitor. Both processes then call where(b) to see if b is really registered, which returns undefined since the process is dead. Both APid and BPid then request an “audit” by the server, to clean out the dead process. After the audit, both processes assume that it is now ok to register b, there is a race condition between the two processes, and one of the registrations fails. Since this is not expected, an exception is raised. (Note that if b were alive then this would be a perfectly valid race condition, where one of the two processes successfully registers the name and the other fails, but the specification says that the registration should always return true for dead processes). This far into our analysis of the error it became clear that it is an altogether rather unwise idea ever to insert a dead process into the process registry. To fix the error we added a simple check (is_process_alive(Pid)) before inserting into the registry. The effect of this change on the performance turned out to be negligible, because is_process_alive is very efficient for local processes. After this change the module passed 20 000 tests, and we were satisfied.
7.
Discussion and Related Work
Actually, the “fix” just described does not really remove all possible race conditions. Since the diagrams made us understand the algorithm much better, we can spot another possible race condition: If APid and BPid try to register the same pid at the same time, and that process dies just after APid and BPid have checked that it is alive, then the same problem we have just fixed, will arise. The rea-
157
run_pcommands.BPid
link
ets:insert_new(proc_reg,[{...}]) = true
{call,{...},_,_}
ets:lookup(proc_reg,{reg,c}) = [{...}]
run_pcommands.APid
ets:is_process_alive(_) = false
ets:insert_new(proc_reg,[{...}]) = false ets:match_object(proc_reg,{{...},_,_}) = ""
{cast,{...}} ets:lookup(proc_reg,{reg,c}) = [{...}]
ets:match_delete(proc_reg,{{...},_,_}) = true ets:is_process_alive(_) = false {_,ok}
ets:monitor(process,_) =_
ets:insert_new(proc_reg,[{...}]) = false
ets:insert_new(proc_reg,{{...},_,{...}}) = true
Figure 4. A problem caused by message overtaking. son that our tests succeeded even so, is that a test must contain three parallel branches to provoke the race condition in its new form— two processes making simultaneous attempts to register, and a third process to kill the pid concerned at the right moment. Because our parallel test cases only run two concurrent branches, then they can never provoke this behavior. The best way to fix the last race condition problem in proc_reg would seem to be to simplify its API, by restricting reg so that a process may only register itself. This, at a stroke, eliminates the risk of two processes trying to register the same process at the same time, and guarantees that we can never try to register a dead process. This simplification was actually made in the production version of the code.
1 2 3 N 4 5 ... 8
2 2 6 20 70 252
K 3 6 90 1680 34650 756756
12870
1010
4 24 2520 369600 6 × 107 1010 ... 1017
5 120 113400 108 3 × 1011 6 × 1014 8 × 1024
Figure 5. Possible interleavings of parallel branches four branches, we could only allow two; with five or more branches, we could allow only one command per branch. This is in itself a restriction that will make some race conditions impossible to detect. Moreover, with more parallel branches, there will be even more possible schedules for PULSE to explore, so race conditions depending on a precise schedule will be correspondingly harder to find. There is thus an engineering trade-off to be made here: allowing greater parallelism in test cases may in theory allow more race conditions to be discovered, but in practice may reduce the probability of finding a bug with each test, while at the same time increasing the cost of each test. We decided to prioritize longer sequences over more parallelism in the test case, and so we chose K = 2. How-
Parallelism in test cases We could, of course, generate test cases with three, four, or even more concurrent branches, to test for this kind of race condition too. The problem is, as we explained in section 4.2, that the number of possible interleavings grows extremely fast with the number of parallel branches. The number of interleavings of K sequences of length N are as presented in Figure 5. The practical consequence is that, if we allow more parallel branches in test cases, then we must restrict the length of each branch correspondingly. The bold entries in the table show the last “feasible” entry in each column—with three parallel branches, we would need to restrict each branch to just three commands; with
158
ever, in the future we plan to experiment with letting QuickCheck randomly choose K and N from the set of feasible combinations. To be clear, note that K only refers to the parallelism in the test case, that is, the number of processes that make calls to the API. The system under test may have hundreds of processes running, many of them controlled by PULSE, independently of K. The problem of detecting race conditions is well studied and can be divided in runtime detection, also referred to as dynamic detection, and analyzing the source code, so called static detection. Most results refer to race conditions in which two threads or processes write to shared memory (data race condition), which in Erlang cannot happen. For us, a race condition appears if there are two schedules of occurring side effects (sending a message, writing to a file, trapping exits, linking to a process, etc) such that in one schedule our model of the system is violated and in the other schedule it is not. Of course, writing to a shared ETS table and writing in shared memory is related, but in our example it is allowed that two processes call ETS insert in parallel. By the atomicity of insert, one will succeed, the other will fail. Thus, there is a valid race condition that we do not want to detect, since it does not lead to a failure. Even in this slightly different setting, known results on race conditions still indicate that we are dealing with a hard problem. For example, Netzer and Miller (1990) show for a number of relations on traces of events that ordering these events on ‘could have been a valid execution’ is an NP-hard problem (for a shared memory model). Klein et al. (2003) show that statically detecting race conditions is NP-complete if more than one semaphore is used. Thus, restricting eqc par statem to execute only two processes in parallel is a pragmatic choice. Three processes may be feasible, but real scalability is not in sight. This pragmatic choice is also supported by recent studies (Lu et al. 2008), where it is concluded that: “Almost all (96%) of the examined concurrency bugs are guaranteed to manifest if certain partial order between 2 threads is enforced.”
nothing to guarantee that functions called in the same sequence will return the same values in different runs. The user still has to make sure that the state of the system is reset properly before each run. Note that the same arguments apply to QuickCheck testing; it is crucial for shrinking and re-testing that input is deterministic and thus it works well to combine QuickCheck and PULSE. False positives In contrast to many race finding methods, that try to spot common patterns leading to concurrency bugs, our approach does not produce false positives and not even does it show races that result in correct execution of the program. This is because we employ property-based testing and classify test cases based on whether the results satisfy correctness properties and report a bug only when a property is violated. Related tools Park and Sen (2008) study atomicity in Java. Their approach is similar to ours in that they use a random scheduler both for repeatability and increased probability of finding atomicity violations. However, since Java communication is done with shared objects and locks, the analysis is rather different. It is quite surprising that our simple randomized scheduler—and even just running tests on a multi-core—coupled with repetition of tests to reduce non-determinism, should work so well for us. After all, this can only work if the probability of provoking the race condition in each test that contains one is reasonably high. In contrast, race conditions are often regarded as very hard to provoke because they occur so rarely. For example, Sen used very carefully constructed schedules to provoke race conditions in Java programs (Sen 2008)—so how can we reasonably expect to find them just by running the same test a few times on a multi-core? We believe two factors make our simple approach to race detection feasible. • Firstly, Erlang is not Java. While there is shared data in Erlang
Hierarchical approach
programs, there is much less of it than in a concurrent Java program. Thus there are many fewer potential race conditions, and a simpler approach suffices to find them.
Note that our tools support a hierarchical approach to testing larger systems. We test proc_reg under the assumption that the underlying ets operations are atomic; PULSE does not attempt to (indeed, cannot) interleave the executions of single ETS operations, which are implemented by C code in the virtual machine. Once we have established that the proc_reg operations behave atomically, then we can make the same assumption about them when testing code that makes use of them. When testing for race conditions in modules that use proc_reg, then we need not, and do not want to, test for race conditions in proc_reg itself. As a result, the PULSE schedules remain short, and the simple random scheduling that we use suffices to find schedules that cause failures.
• Secondly, we are searching for race conditions during unit test-
ing, where each test runs for a short time using only a relatively small amount of code. During such short runs, there is a fair chance of provoking race conditions with any schedule. Finding race conditions during whole-program testing is a much harder problem. Chess, developed by Musuvathi et al. (2008), is a system that shares many similarities with PULSE. Its main component is a scheduler capable of running the program deterministically and replaying schedules. The key difference between Chess and PULSE is that the former attempts to do an exhaustive search and enumerate all the possible schedules instead of randomly probing them. Several interesting techniques are employed, including prioritizing schedules that are more likely to trigger bugs, making sure that only fair schedules are enumerated and avoiding exercising schedules that differ insignificantly from already visited ones.
Model Checking One could argue that the optimal solution to finding race conditions problem would be to use a model checker to explore all possible interleavings. The usual objections are nevertheless valid, and the rapidly growing state space for concurrent systems makes model checking totally infeasible, even with a model checker optimized for Erlang programs, such as McErlang (Fredlund and Svensson 2007). Further it is not obvious what would be the property to model check, since the atomicity violations that we search for can not be directly translated into an LTL model checking property.
Visualization Visualization is a common technique used to aid understanding software. Information is extracted statically from source code or dynamically from execution and displayed in graphical form. Of many software visualization tools a number are related to our work. Topol et al. (1995) developed a tool that visualizes executions of parallel programs and shows, among other things, a trace of messages sent between processes indicating the happened-before relationships. Work of Jerding et al. (1997) is able to show dynamic
Input non-determinism PULSE provides deterministic scheduling. However, in order for tests to be repeatable we also need the external functions to behave consistently across repeated runs. While marking them as sideeffects will ensure that they are only called serially, PULSE does
159
call-graphs of object-oriented programs and interaction patterns between their components. Arts and Fredlund (2002) describe a tool that visualizes traces of Erlang programs in form of abstract state transition diagrams. Artho et al. (2007) develop a notation that extends UML diagrams to also show traces of concurrent executions of threads, Maoz et al. (2007) create event sequence charts that can express which events “must happen” in all possible scenarios.
8.
Cyrille Artho, Klaus Havelund, and Shinichi Honiden. Visualization of concurrent program executions. In COMPSAC ’07: Proc. of the 31st Annual International Computer Software and Applications Conference, pages 541–546, Washington, DC, USA, 2007. IEEE Computer Society. ˚ Fredlund. Trace analysis of Erlang programs. Thomas Arts and Lars-Ake SIGPLAN Notices, 37(12):18–24, 2002. Thomas Arts, John Hughes, Joakim Johansson, and Ulf Wiger. Testing Telecoms Software with Quviq QuickCheck. In ERLANG ’06: Proc. of the 2006 ACM SIGPLAN workshop on Erlang. ACM, 2006. Koen Claessen and John Hughes. QuickCheck: a lightweight tool for random testing of Haskell programs. In ICFP ’00: Proc. of the fifth ACM SIGPLAN international conference on Functional programming, pages 268–279, New York, NY, USA, 2000. ACM. Mats Cronqvist. Troubleshooting a large Erlang system. In ERLANG ’04: Proc. of the 2004 ACM SIGPLAN workshop on Erlang, pages 11–15, New York, NY, USA, 2004. ACM. ˚ Fredlund and Hans Svensson. McErlang: a model checker for Lars-Ake a distributed functional programming language. SIGPLAN Not., 42(9): 125–136, 2007. Emden R. Gansner and Stephen C. North. An open graph visualization system and its applications. Software - Practice and Experience, 30: 1203–1233, 1999. M. P. Herlihy and J. M. Wing. Axioms for concurrent objects. In POPL ’87: Proc. of the 14th ACM SIGACT-SIGPLAN symposium on Principles of Prog. Lang., pages 13–26, New York, NY, USA, 1987. ACM. John Hughes. QuickCheck Testing for Fun and Profit. In 9th Int. Symp. on Practical Aspects of Declarative Languages. Springer, 2007. Dean F. Jerding, John T. Stasko, and Thomas Ball. Visualizing interactions in program executions. In In Proc. of the 19th International Conference on Software Engineering, pages 360–370, 1997. Klein, Lu, and Netzer. Detecting race conditions in parallel programs that use semaphores. Algorithmica, 35:321–345, 2003. Leslie Lamport. How to make a multiprocessor computer that correctly executes multiprocess programs. IEEE Transactions on Computers, 28 (9):690–691, 1979. Leslie Lamport. Time, clocks, and the ordering of events in a distributed system. Commun. ACM, 21(7):558–565, 1978. Shan Lu, Soyeon Park, Eunsoo Seo, and Yuanyuan Zhou. Learning from mistakes: a comprehensive study on real world concurrency bug characteristics. SIGARCH Comput. Archit. News, 36(1):329–339, 2008. Shahar Maoz, Asaf Kleinbort, and David Harel. Towards trace visualization and exploration for reactive systems. In VLHCC ’07: Proc. of the IEEE Symposium on Visual Languages and Human-Centric Computing, pages 153–156, Washington, DC, USA, 2007. IEEE Computer Society. Madanlal Musuvathi, Shaz Qadeer, Thomas Ball, G´erard Basler, Piramanayagam Arumuga Nainar, and Iulian Neamtiu. Finding and reproducing heisenbugs in concurrent programs. In OSDI, pages 267–280, 2008. Robert H. B. Netzer and Barton P. Miller. On the complexity of event ordering for shared-memory parallel program executions. In In Proc. of the 1990 Int. Conf. on Parallel Processing, pages 93–97, 1990. Chang-Seo Park and Koushik Sen. Randomized active atomicity violation detection in concurrent programs. In SIGSOFT ’08/FSE-16: Proc. of the 16th ACM SIGSOFT International Symposium on Foundations of software engineering, pages 135–145, New York, NY, USA, 2008. ACM. Koushik Sen. Race directed random testing of concurrent programs. SIGPLAN Not., 43(6):11–21, 2008. ˚ Fredlund. A more accurate semantics for distributed H. Svensson and L.-A. Erlang. In Erlang ’07: Proc. of the 2007 SIGPLAN Erlang Workshop, pages 43–54, New York, NY, USA, 2007. ACM. B. Topol, J.T. Stasko, and V. Sunderam. Integrating visualization support into distributed computing systems. Proc. of the 15th Int. Conf. on: Distributed Computing Systems, pages 19–26, May-Jun 1995. Ulf T. Wiger. Extended process registry for Erlang. In ERLANG ’07: Proc. of the 2007 SIGPLAN workshop on ERLANG Workshop, pages 1–10, New York, NY, USA, 2007. ACM.
Conclusions
Concurrent code is hard to debug and therefore hard to get correct. In this paper we present an extension to QuickCheck, a user level scheduler for Erlang (PULSE), and a tool for visualizing concurrent executions that together help in debugging concurrent programs. The tools allow us to find concurrency errors on a module testing level, whereas industrial experience is that most of them slip through to system level testing, because the standard scheduler is deterministic, but behaves differently in different timing contexts. We contributed eqc par statem, an extension of the state machine library for QuickCheck that enables parallel execution of a sequence of commands. We generate a sequential prefix to bring the system into a certain state and continue with parallel execution of a suffix of independent commands. As a result we can provoke concurrency errors and at the same time get good shrinking behavior from the test cases. We contributed with PULSE, a user level scheduler that enables scheduling of any concurrent Erlang program in such a way that an execution can be repeated deterministically. By randomly choosing different schedules, we are able to explore more execution paths than without such a scheduler. In combination with QuickCheck we get in addition an even better shrinking behavior, because of the repeatability of test cases. We contributed with a graph visualization method and tool that enabled us to analyze concurrency faults more easily than when we had to stare at the produced traces. The visualization tool depends on the output produced by PULSE, but the use of computing the “happens before” relation to simplify the graph is a general principle. We evaluated the tools on a real industrial case study and we detected two race conditions. The first one by only using eqc par statem; the fault had been noticed before, but now we did not need to instrument the code under test with yield() commands. The first and second race condition could easily be provoked by using PULSE. The traces recorded by PULSE were visualized and helped us in clearly identifying the sources of the two race conditions. By analyzing the graphs we could even identify a third possible race condition, which we could provoke if we allowed three instead of two parallel processes in eqc par statem. Our contributions help Erlang software developers to get their concurrent code right and enables them to ship technologically more advanced solutions. Products that otherwise might have remained a prototype, because they were neither fully understood nor tested enough, can now make it into production. The tool PULSE and the visualization tool are available under the Simplified BSD License and have a commercially supported version as part of Quviq QuickCheck.
Acknowledgments This research was sponsored by EU FP7 Collaborative project ProTest, grant number 215868.
References Joe Armstrong. Programming Erlang: Software for a Concurrent World. Pragmatic Bookshelf, July 2007.
160
Partial Memoization of Concurrency and Communication Lukasz Ziarek
KC Sivaramakrishnan
Suresh Jagannathan
Department of Computer Science Purdue University {lziarek,chandras,suresh}@cs.purdue.edu
Abstract
1.
Memoization is a well-known optimization technique used to eliminate redundant calls for pure functions. If a call to a function f with argument v yields result r, a subsequent call to f with v can be immediately reduced to r without the need to re-evaluate f ’s body. Understanding memoization in the presence of concurrency and communication is significantly more challenging. For example, if f communicates with other threads, it is not sufficient to simply record its input/output behavior; we must also track inter-thread dependencies induced by these communication actions. Subsequent calls to f can be elided only if we can identify an interleaving of actions from these call-sites that lead to states in which these dependencies are satisfied. Similar issues arise if f spawns additional threads. In this paper, we consider the memoization problem for a higher-order concurrent language whose threads may communicate through synchronous message-based communication. To avoid the need to perform unbounded state space search that may be necessary to determine if all communication dependencies manifest in an earlier call can be satisfied in a later one, we introduce a weaker notion of memoization called partial memoization that gives implementations the freedom to avoid performing some part, if not all, of a previously memoized call. To validate the effectiveness of our ideas, we consider the benefits of memoization for reducing the overhead of recomputation for streaming, server-based, and transactional applications executed on a multi-core machine. We show that on a variety of workloads, memoization can lead to substantial performance improvements without incurring high memory costs.
Eliminating redundant computation is an important optimization supported by many language implementations. One important instance of this optimization class is memoization (Liu and Teitelbaum 1995; Pugh and Teitelbaum 1989; Acar et al. 2003), a wellknown dynamic technique that can be utilized to avoid performing a function application by recording the arguments and results of previous calls. If a call is supplied an argument that has been previously cached, the execution of the function body can be elided, with the corresponding result immediately returned instead. When functions perform effectful computations, leveraging memoization becomes significantly more challenging. Two calls to a function f that performs some stateful computation need not generate the same result if the contents of the state f uses to produce its result are different at the two call-sites. Concurrency and communication introduce similar complications. If a thread calls a function f that communicates with functions invoked in other threads, then memo information recorded with f must include the outcome of these actions. If f is subsequently applied with a previously seen argument, and its communication actions at this call-site are the same as its effects at the original application, re-evaluation of the pure computation in f ’s body can be avoided. Because of thread interleavings, synchronization, and non-determinism introduced by scheduling choices, making such decisions is non-trivial. Nonetheless, we believe memoization can be an important component in a concurrent programming language runtime. Our belief is enforced by the widespread emergence of multi-core platforms, and renewed interest in streaming (Gordon et al. 2006), speculative (Pickett and Verbrugge 2005) and transactional (Harris and Fraser 2003; Adl-Tabatabai et al. 2006) abstractions to program these architectures. For instance, optimistic concurrency abstractions rely on efficient control and state restoration mechanisms. When a speculation fails because a previously available computation resource becomes unavailable, or when a transaction aborts due to a serializability violation and must be retried (Harris et al. 2005), their effects must be undone. Failure represents wasted work, both in terms of the operations performed whose effects must now be erased, and in terms of overheads incurred to implement state restoration; these overheads include logging costs, read and write barriers, contention management, etc. One way to reduce this overhead is to avoid subsequent re-execution of those function calls previously executed by the failed computation whose results are unchanged. The key issue is understanding when utilizing memoized information is safe, given the possibility of concurrency, communication, and synchronization among threads. In this paper, we consider the memoization problem for a higher-order concurrent language in which threads communicate through synchronous message-passing primitives (e.g. Concurrent
Categories and Subject Descriptors D.3.3 [Language Constructs and Features]: Concurrent programming structures; D.1.3 [Concurrent Programming]; D.3.1 [Formal Definitions and Theory]: Semantics General Terms Design, Experimentation, Languages, Performance, Theory, Algorithms Keywords Concurrent Programming, Partial Memoization, Software Transactions, Concurrent ML, Multicore Systems.
Permission to make digital or hard copies of all or part of this work for personal or classroom use is granted without fee provided that copies are not made or distributed for profit or commercial advantage and that copies bear this notice and the full citation on the first page. To copy otherwise, to republish, to post on servers or to redistribute to lists, requires prior specific permission and/or a fee. ICFP’09, August 31–September 2, 2009, Edinburgh, Scotland, UK. c 2009 ACM 978-1-60558-332-7/09/08. . . $5.00. Copyright
161
Introduction
ML (Reppy 1999)). A synchronization event acknowledges the existence of an external action performed by another thread willing to send or receive data. If such events occur within a function f whose applications are memoized, then avoiding re-execution at a call-site c is only possible if these actions are guaranteed to succeed at c. In other words, using memo information requires discovery of interleavings that satisfy the communication constraints imposed by a previous call. If we can identify a global state in which these constraints are satisfied, the call to c can be avoided; if there exists no such state, then the call must be performed. Because finding such a state can be expensive (it may require an unbounded state space search), we consider a weaker notion of memoization: by recording the context in which a memoization constraint was generated, implementations can always choose to simply resume execution of the function at the program point associated with the constraint using the saved context. In other words, rather than requiring global execution to reach a state in which all constraints in a memoized application are satisfied, partial memoization gives implementations the freedom to discharge some fraction of these constraints, performing the rest of the application as normal. Although our description and formalization is developed in the context of message-based communication, the applicability of our solution naturally extends to shared-memory communication as well given the simple encoding of the latter in terms of the former (Reppy 1999). Whenever a constraint built during memoization is discharged on a subsequent application, there is a side-effect on the global state that occurs. For example, consider a communication constraint associated with a memoized version of a function f that expects a thread T to receive data d on channel c. To use this information at a subsequent call, we must identify the existence of T , and having done so, must propagate d along c for T to consume. Thus, whenever a constraint is satisfied, an effect that reflects the action represented by that constraint is performed. We consider the set of constraints built during memoization as forming an ordered log, with each entry in the log representing a condition that must be satisfied to utilize the memoized version, and an effect that must be performed if the condition holds. The point of memoization for our purposes is thus to avoid performing the pure computations that execute between these effectful operations. 1.1
ically created channels through which they produce and consume values. Since communication is synchronous, a thread wishing to communicate on a channel that has no ready recipient must block until one exists, and all communication on channels is ordered. Our formal treatment does not consider references, but there are no additional complications that ensue in order to handle them; our implementation supports all of Standard ML. In this context, deciding whether a function application can be avoided based on previously recorded memo information depends upon the value of its arguments, its communication actions, channels it creates, threads it spawns, and the return value it produces. Thus, the memoized result of a call to a function f can be used at a subsequent call if (a) the argument given matches the argument previously supplied; (b) recipients for values sent by f on channels in an earlier memoized call are still available on those channels; (c) values consumed by f on channels in an earlier call are again ready to be sent to other threads; (d) channels created in an earlier call have the same actions performed on them, and (e) threads created by f can be spawned with the same arguments supplied in the memoized version. Ordering constraints on all sends and receives performed by the procedure must also be enforced. A successful application of a memoized call yields a new state in which the effects captured within the constraint log have been performed; thus, the values sent by f are received by waiting recipients, senders on channels from which f expects to receive values propagate these values on those channels, and channels and threads that f is expected to create are created. To avoid making a call, a send action performed within the applied function, for example, will need to be paired with a receive operation executed by some other thread. Unfortunately, there may be no thread currently scheduled that is waiting to receive on this channel. Consider an application that calls a memoized function f which (a) creates a thread T that receives a value on channel c, and (b) sends a value on c computed through values received on other channels that is then consumed by T . To safely use the memoized return value for f nonetheless still requires that T be instantiated, and that communication events executed in the first call can still be satisfied (e.g., the values f previously read on other channels are still available on those channels). Ensuring these actions can succeed may involve an exhaustive exploration of the execution state space to derive a schedule that allows us to consider the call in the context of a global state in which these conditions are satisfied. Because such an exploration may be infeasible in practice, our formulation also supports partial memoization. Rather than requiring global execution to reach a state in which all constraints in a memoized application are satisfied, partial memoization gives implementations the freedom to discharge some fraction of these constraints, performing the rest of the application as normal.
Contributions
Besides providing a formal characterization of these ideas, we also present performance evaluation of two parallel benchmarks. We consider the effect of memoization on improving performance of multi-threaded CML applications executing on a multicore architecture. Our results indicate that memoization can lead to substantial runtime performance improvement over a non-memoized version of the same program, with only modest increases in memory overhead (15% on average). To the best of our knowledge, this is the first attempt to formalize a memoization strategy for effectful higher-order concurrent languages, and to provide an empirical evaluation of its impact on improving wall-clock performance for multi-threaded workloads. The paper is organized as follows. The programming model is defined in Section 2. Motivation for the problem is given in Section 3. The formalization of our approach is given in Sections 4 and Section 5. A detailed description of our implementation and results are given in Sections 6 and 7. We discuss previous work and provide conclusions in Section 8.
2.
3.
Motivation
As a motivating example, we consider how memoization can be profitably utilized in a concurrent message-passing red-black tree implementation. The data structure supports concurrent insertion, deletion, and traversal operations. A node in the tree is a tuple containing the node’s color, an integer value, and links to its left and right children. Associated with every node is a thread that reads from an input channel, and outputs the node’s data on an output channel, effectively encoding a server. Accessing and modifying a tree node’s data is thus accomplished through a communication protocol with the node’s input and output channels. Abstractly, a read corresponds to a receive operation ( recv ) from a node’s output channel, and a write to a send ( send ) on a node’s input channel.
Programming Model
Our programming model is a simple synchronous message-passing dialect of ML similar to CML. Threads communicate using dynam-
162
When a node is initially created, we instantiate two new channels ( channel() ), and spawn a server. The function server , the concrete representation of a node, chooses between accepting a communication on the incoming channel cIn (corresponding to a write operation) and sending a communication on the outgoing channel cOut (corresponding to a read operation), synchronizes on the communication, and then updates the server through a recursive call.
T1 T2
R48 R48
R76 R76 48 R85 R85 R93
76
67
R79
85
79
85
93 C99 86
datatype rbtree = N of {cOut: node chan, cIn: node chan} and node = Node of {color: color, value: int, left:rbtree, right:rbtree} | EmptyNode fun node(c:color, v:int, l:rbtree, r:rbtree):rbtree = let val (cOut, cIn) = (channel(), channel()) val node = Node{color=c,value=v,left=l,right=r} fun server(node) = sync( choose([wrap(sendEvt(cOut, node), (fn x => server(node)) ), wrap(recvEvt(cIn), (fn x => server(x)))])) in spawn(fn () => server(node)); N{cOut=cOut, cIn=cIn}) end
Figure 1. Memoization can be used to avoid redundant tree traversals. In this example, two threads traverse a red-black tree. Each node in the tree is represented as a process that outputs the current value of the node, and inputs new values. The shaded path illustrates memoization potential.
T1 T2
R48 R48
R76 R76 48
For example, the procedure contains queries the tree to determine if a node containing its integer argument is present. It takes as input the number being searched, and the root of the tree from which to begin the traversal. The recvT operation reads the value from each node, and based on this value check navigates the tree:
R84 R85 76 R93 67
R79
85
79
fun recvT(N{cOut, cIn}:rbtree) = recv(cOut) fun contains (n:int, tree:rbtree):bool = let fun check(n, tree) = (case recvT(tree) of Node {color,value,left,right} => (case Int.compare (value, n) of EQUAL => true | GREATER => check (n,left) | LESSER => check (n,right)) | EmptyNode => false) in check(n, tree) end
93
C99
86
Figure 2. Even if the constraints stored in a memoized call cannot be fully satisfied at a subsequent call, it may be possible to discharge some fraction of these constraints, retaining some of the optimization benefits memoization affords.
Because of the actions of T2, subsequent calls to contains with argument 79 cannot avail of the information recorded during evaluation of the initial memoized call. As a consequence of T2’s update, a subsequent traversal by T1 would observe a change to the tree. Note however, that even though the immediate parent of 79 has changed in color, the path leading up to node 85 has not (see Fig. 2). By leveraging partial memoization on the earlier memoized call to contains , a traversal attempting to locate node 79 can avoid traversing this prefix, if not the entire path. Notice that if the node with value 85 was later recolored, and assuming structural equality of nodes is used to determine memoization feasibility, full memoization would once again be possible.
Memoization can be leveraged to avoid redundant traversals of the tree. Consider the red/black tree shown in Fig. 1. Triangles represent abstracted portions of the tree, red nodes are unbolded, and black nodes are marked as bold circles. Suppose we memoize the call to contains which finds the node containing the value 79. Whenever a subsequent call to contains attempts to find the node containing 79, the traversal can directly use the result of the previous memoized call if both the structure of the tree along the path to the node, and the values of the nodes on the path remain the same. Both of these properties are reflected in the values transmitted by node processes. The path is depicted by shading relevant nodes in gray in Fig. 1. More concretely, we can avoid recomputing the traversal if communication with node processes remains unchanged. Informally, memo information associated with a function f can be leveraged to avoid subsequent applications of f if communication actions performed by the memoized call can be satisfied in these later applications. Thus, to successfully leverage memo information for a call to contains with input 79, we would need to ensure a subsequent call of the function with the same argument would be able to receive the sequence of node values: (red, 48), (black, 76), (red, 85), and (black, 79) in that order during a traversal. In Fig. 1 thread T1 can take advantage of memoization, while thread T2 subsequently recolors the node containing 85.
As this example suggests, a key requirement for effectively utilizing memoized function applications is the ability to track communication (and other effectful) actions performed by previous instantiations. Provided that the global state would permit these same actions (or some subset thereof) to succeed if a function is re-executed with the same inputs, memoization can be employed to avoid recomputing applications, or to reduce the amount of the application that is executed. We note that although the example presented dealt with memoization of a function that operates over base integer values, our solution detailed in the following sections considers memoization in the context of any value that can appear on a channel, including closures, vectors, and datatype instances.
163
4.
Syntax and Semantics
of a memoized function, and whose associated effects must be discharged if the constraint is satisfied. To record constraints, we augment our semantics to include a memo store (σ), a map that given a function identifier and an argument value, returns the set of constraints and result value that was previously recorded for a call to that function with that argument. If the set of constraints returned by the memo store is satisfied in the current state (and their effects performed), then the return value can be used and the application elided. The memo store contains only one function/value pair for simplicity of the presentation. We can envision extending the memo store to contain multiple memoized versions of a function based on its arguments or constraints. We utilize two thread contexts t[e] and tC [e], the former to indicate that evaluation of terms should be captured within the memo store, and the latter to indicate that previously recorded constraints should be discharged. We elaborate on their use below.
Our semantics is defined in terms of a core call-by-value functional language with threading and communication primitives. Communication between threads is achieved using synchronous channels. For perspicuity, we first present a simple multi-threaded language with synchronous channel based communication. We then extend this core language with memoization primitives, and subsequently consider refinements of this semantics to support partial memoization. Although the core language has no support for selective communication, extending it to support choice is straightforward. Memoization would simply record the result of the choice and replay would only be possible if the recorded choice was satisfiable. In the following, we write α to denote a sequence of zero or more elements, β.α to denote sequence concatenation, and 0/ to denote an empty sequence. Metavariables x and y range over variables, t ranges over thread identifiers, l ranges over channels, v ranges over values, and α, β denote tags that label individual actions in a program’s execution. We use P to range over program states, E for evaluation contexts, and e for expressions. Our communication model is a message-passing system with synchronous send and receive operations. We do not impose a strict ordering of communications on channels; communication actions on the same channel by different threads are paired nondeterministically. To model asynchronous sends, we simply spawn a thread to perform the send. Spawning an expression (that evaluates to a thunk) creates a new thread in which the application of the thunk is performed. 4.1
The definition of the language augmented with memoization support is given in Fig. 4. We now define evaluation using a new relation ( =⇒ ) defined over two global configurations. In one case, a configuration consists of a program state (P) and a memo store (σ). This configuration is used when evaluation does not leverage memoized information. The second configuration is defined in terms of a thread id and constraint sequence pair ((t,C)), a program state (P), and a memo store (σ); transitions use this configuation when discharging constraints recorded from a previous memoized application. In addition, a thread state is augmented to hold an additional structure. The memo state (θ) records the function identifier (δ), the argument (v) supplied to the call, the context (E) in which the call is performed, and the sequence of constraints (C) that are built during the evaluation of the application being memoized.
Language
The syntax and semantics of the language are given in Fig. 3. Expressions are either variables, locations that represent channels, λabstractions, function applications, thread creation operations, or communication actions that send and receive messages on channels. We do not consider references in this core language as they can be modeled in terms of operations on channels (Reppy 1999). A thread context t[E[e]] denotes an expression e available for execution by a thread with identifier t within context E. Evaluation is specified via a relation ( 7−→ ) that maps a program state (P) to another program state. Evaluation rules are applied up to commutativity of parallel composition (k). An evaluation step is marked with a tag that indicates the action performed by that step. As shortα hand, we write P 7−→ P0 to represent the sequence of actions α that transforms P to P0 . Application (rule A PP) substitutes the argument value for free occurrences of the parameter in the body of the abstraction, and channel creation (rule C HANNEL) results in the creation of a new location that acts as a container for message transmission and reception. A spawn action (rule S PAWN), given an expression e that evaluates to a thunk changes the global state to include a new thread in which the thunk is applied. A communication event (rule C OMM) synchronously pairs a sender attempting to transmit a value along a specific channel in one thread with a receiver waiting on the same channel in another thread. 4.2
Constraints built during a memoized function application define actions that must be satisfied at subsequent call-sites in order to avoid complete re-evaluation of the function body. For a communication action, a constraint records the location being operated upon, the value sent or received, the action performed (R for receive and S for send), and the continuation immediately prior to the action being performed; the application resumes evaluation from this point if the corresponding constraint could not be discharged. For a spawn operation, the constraint records the action (Sp), the expression being spawned, and the continuation immediately prior to the spawn. For a channel creation operation (Ch), the constraint records the location of the channel and the continuation immediately prior to the creation operation. Returns are also modeled as constraints (Rt, v) where v is the return value of the application being memoized. Consider an application of function f to value v that has been memoized. Since subsequent calls to f with v may not be able to discharge all constraints, we need to record the program points for all communication actions within f that represent potential resumption points from which normal evaluation of the function body proceeds; these continuations are recorded as part of any constraint that can fail 1 (communication actions, and return constraints as described below). But, since the calling contexts at these other callsites are different from the original, we must be careful to not include them within saved continuations recorded within these constraints. Thus, the contexts recorded as part of the saved constraint during memoization only define the continuation of the action up to the return point of the function; the memo state (θ) stores the evaluation context representing the caller’s continuation. This context is restored once the application completes (see rule R ET).
Partial Memoization
The core language presented above provides no facilities for memoization of the functions it executes. To support memoization, we must record, in addition to argument and return values, synchronous communication actions, thread spawns, channel creation etc. as part of the memoized state. These actions define a log of constraints (C) that must be satisfied at subsequent applications
1 We
also record continuations on non-failing constraints; while not strictly necessary, doing so simplifies our correctness proofs.
164
P ROGRAM S TATES :
S YNTAX : P e ∈ Exp v ∈ Val
::= ::= | ::=
PkP | t[e] x | y | v | e(e) | spawn(e) mkCh() | send(e, e) | recv(e) unit | λ x.e | l
P t x, y l α, β
∈ ∈ ∈ ∈ ∈
Process Tid Var Channel Tag =
{App,Ch, Spn,Com}
E VALUATION C ONTEXTS : E ::= [ ] | E(e) | v(E) | spawn(E) | send(E, e) | send(l, E) | recv(E)
(A PP )
(C HANNEL )
l fresh
App
Pkt[E[λ x.e (v)]] 7−→ Pkt[E[e[v/x]]]
(S PAWN )
Ch
Pkt[E[mkCh()]] 7−→ Pkt[E[l]]
(C OMM )
t0 fresh Spn
P = P0 kt[E[send(l, v)]]kt0 [E0 [recv(l)]] Com
P 7−→ P0 kt[E[unit]]kt0 [E0 [v]]
Pkt[E[spawn(λ x.e)]] 7−→ Pkt[E[unit]]kt0 [e[unit/x]]
Figure 3. A concurrent language with synchronous communication. If function f calls function g , then actions performed by g must be satisfiable in any call that attempts to leverage the memoized version of f . Consider the following program fragment: let fun f(...) = ... let fun g(...) = ... in ... end in ... g(...) ... end
potentially avoided; if not, its evaluation is memoized by rule A PP. If a memoized call is applied, we must examine the set of associated constraints that can be discharged. To do so, we employ an auxiliary relation ℑ defined in Fig. 5. Abstractly, ℑ checks the global state to determine which communication, channel creation, and spawn creation constraints (the possible effectful actions in our language) can be satisfied, and returns a set of failed constraints, representing those actions that could not be satisfied. The thread context (tC [e]) is used to signal the utilization of memo information. The failed constraints are added to the original thread context. Rule M EMO A PP yields a new global configuration whose thread id and constraint sequence ((t,C)) corresponds to the constraints satisfiable in the current global state (defined as C00 ) for thread t as defined by ℑ. These constraints, when discharged, will leave the thread performing the memoized call in a new state in which the evaluation of the call is the expression associated with the first failed constraint returned by ℑ. As we describe below in Sec 4.3, there is always at least one such constraint, namely Rt , the return constraint that holds the return value of the memoized call. We also introduce a rule to allow the completion of memo information use (rule E ND M EMO). The rule installs the continuation of the first currently unsatisfied constraint; no further constraints are subsequently examined. In this formulation, the other failed constraints are simply discarded; we present an extension of this semantics in Section. 4.6 that make use of them.
send(c,v) ...
If we encounter an application of f after it has been memoized, then g ’s communication action (i.e., the send of v on c ) must be satisfiable at the point of the application to avoid performing the call. We therefore associate a call stack of constraints (θ) with each thread that defines the constraints seen thus far, requiring the constraints computed for an inner application to be satisfiable for any memoization of an outer one. The propagation of constraints to the memo states of all active calls is given by the operation shown in Fig. 4. Channels created within a memoized function must be recorded in the constraint sequence for that function (rule C HANNEL). Consider a function that creates a channel and subsequently initiates communication on that channel. If a call to this function was memoized, later applications that attempt to avail of memo information must still ensure that the generative effect of creating the channel is not omitted. Function evaluation now associates a label with function evaluation that is used to index the memo store (rule F UN). If a new thread is spawned within a memoized application, a spawn constraint is added to the memo state, and a new global state is created that starts memoization of the actions performed by the newly spawned thread (rule S PAWN). A communication action performed by two functions currently being memoized are also appropriately recorded in the corresponding memo state of the threads that are executing these functions (rule C OMM). When a memoized application completes, its constraints are recorded in the memo store (rule R ET). When a function f is applied to argument v, and there exists no previous invocation of f to v recorded in the memo store, the function’s effects are tracked and recorded (rule A PP). Until an application of a function being memoized is complete, the constraints induced by its evaluation are not immediately added to the memo store. Instead, they are maintained as part of the memo state (θ) associated with the thread in which the application occurs. The most interesting rule is the one that deals with determining how much of an application of a memoized function can be elided (rule M EMO A PP). If an application of function f with argument v has been recorded in the memo store, then the application can be
4.3
Constraint Matching
The constraints built as a result of evaluating these rules are discharged by the rules shown in Fig. 6. Each rule in Fig. 6 is defined with respect to a thread id and constraint sequence. Thus, at any given point in its execution, a thread is either building up memo constraints (i.e., the thread is of the form t[e]) within an application for subsequent calls to utilize, or attempting to discharge these constraints (i.e., the thread is of the form tC [e]) for applications indexed in the memo store. The function ℑ leverages the evaluation rules defined in Fig. 6 by examining the global state and determining which constraints can be discharged, except for the return constraint. ℑ takes a constraint set (C) and a program state (P) and returns a sequence of unmatchable constraints (C0 ). Send and receive constraints are matched with threads blocked in the global program state on the opposite communication action. Once a thread has been matched with a constraint it is no longer a candidate for future communication since its communication action is consumed by the constraint. This guarantees that
165
S YNTAX :
P ROGRAM S TATES : P v ∈ Val E ∈ Context
δ c C
∈ ∈ ∈
MemoId FailableConstraint= Constraint =
σ θ α, β
∈ ∈ ∈
MemoStore MemoState Tag
PkP | hθ, t[e]i | hθ, tC [e]i unit | λδ x.e | l
::= ::=
C ONSTRAINT A DDITION : θ0 = {(δ, v, E,C.C)|(δ, v, E,C) ∈ θ} θ,C θ0
(C HANNEL )
= = =
({R, S} × Loc × Val) + Rt (FailableConstraint × Exp)+ ((Sp × Exp) × Exp) + ((Ch × Location) × Exp) MemoId × Val → Constraint∗ MemoId × Val × Context × Constraint∗ {Ch, Spn, Com, Fun, App, Ret, MCom, MCh, MSp, MemS, MemE, MemR, MemP}
(F UN )
θ, ((Ch, l), E[mkCh()]) θ0
δ fresh
l fresh
Fun
Ch
Pkhθ, t[E[mkCh()]]i, σ =⇒ Pkhθ0 , t[E[l]]i, σ
Pkhθ, t[E[λ x.e]]i, σ =⇒ Pkhθ, t[E[λδ x.e]]i, σ
(S PAWN )
(C OMM )
t0 fresh θ, ((Sp, λδ x.e(unit)), E[spawn(λδ x.e)]) θ0 tk = hθ0 , t[E[unit]]i / t0 [λδ x.e(unit)]i ts = h0,
P = P0 khθ, t[E[send(l, v)]]ikhθ0 , t0 [E0 [recv(l)]]i θ, ((S, l, v), E[send(l, v)]) θ00 θ0 , ((R, l, v), E0 [recv(l)]) θ000 ts = hθ00 , t[E[unit]]i tr = hθ000 , t0 [E0 [v]]i Com
Spn
P, σ =⇒ P0 kts ktr , σ
Pkhθ, t[E[spawn(λδ x.e)]]i, σ =⇒ Pktk kts , σ
(R ET )
(A PP ) / (δ, v) 6∈ Dom(σ) θ = (δ, v, E, 0)
θ = (δ, v, E,C) Ret
Pkhθ.θ, t[v0 ]i, σ =⇒ Pkhθ, t[E[v0 ]]i, σ[(δ, v) 7→ C.(Rt, v0 )]
App
Pkhθ, t[E[λδ x.e (v)]]i, σ =⇒ Pkhθ.θ, t[e[v/x]]i, σ
(M EMO A PP )
(E ND M EMO ) (δ, v) ∈ Dom(σ) σ(δ, v) = C ℑ(C, P) = C0 C = C00 .C0
C = (c, e0 )
MemS
Pkhθ, t[E[λδ x.e (v)]]i, σ =⇒ (t,C00 ), Pkhθ, tc0 [E[λδ x.e (v)]]i, σ
MemE
/ Pkhθ, tC.C [E[λδ x.e (v)]]i, σ =⇒ Pkhθ, t[E[e0 ]]i, σ (t, 0),
Figure 4. A concurrent language supporting memoization of synchronous communication and dynamic thread creation. the candidate function will communicate at most once with each thread in the global state. Although a thread may in fact be able to communicate more than once with the candidate function, determining this requires arbitrary look ahead and is infeasible in practice. We discuss the implications of this restriction in Section 4.5.
receive value v on channel l , and there exists a thread able to send v on l , evaluation proceeds to a state in which the communication succeeds (the receiving thread now evaluates in a context in which the receipt of the value has occurred), and the constraint is removed from the set of constraints that need to be matched (rule MR ECV). Note also that the sender records the fact that a communication with a matching receive took place in the thread’s memo state, and the receiver does likewise. Any memoization of the sender must consider the receive action that synchronized with the send, and the application in which the memoized call is being examined must record the successful discharge of the receive action. In this way, the semantics permits consideration of multiple nested memoization actions. If the current constraint expects to send a value v on channel l , and there exists a thread waiting on l , the constraint is also satisfied (rule MS END). A send operation can match with any waiting receive action on that channel. The semantics of synchronous communication allows us the freedom to consider pairings of sends with receives other than the one it communicated with in the original memoized execution. This is because a receive action places no restriction on either the value it reads, or the specific sender that provides the value. If there is no matching receiver, the constraint fails. Observe that there is no evaluation rule for the Rt constraint that can consume it. This constraint contains the return value of the memoized function (see rule R ET). If all other constraints have
Thus, a spawn constraint (rule MS PAWN) is always satisfied, and leads to the creation of a new thread of control. Observe that the application evaluated by the new thread is now a candidate for memoization if the thunk was previously applied and its result is recorded in the memo store. A channel constraint of the form ((Ch,l), E[e]) (rule MC H) creates a new channel location l0 , and replaces all occurrences of l found in the remaining constraint sequence for this thread with l0 ; the channel location may be embedded within send and receive constraints, either as the target of the operation, or as the argument value being sent or received. Thus, discharging a channel constraint ensures that the effect of creating a new channel performed within an earlier memoized call is preserved on subsequent applications. The renaming operation ensures that later send and receive constraints refer to the new channel location. Both channel creation and thread creation actions never fail – they modify the global state with a new thread and channel, respectfully, but impose no preconditions on the state in order for these actions to succeed. MCom
There are two communication constraint matching rules ( =⇒ ), both of which may indeed fail. If the current constraint expects to
166
= = = = = =
ℑ(((S, l, v), e).C, Pkhθ, t[E[recv(l)]]i) ℑ(((R, l, v), e).C, Pkhθ, t[E[send(l, v)]]i) ℑ((Rt, v), P) ℑ(((Ch, l), e).C, P) ℑ(((Sp, e0 ), e).C, P) ℑ(C, P)
let fun f() = (send(c,1); send(c,2)) fun g() = (recv(c);recv(c)) in spawn(g); f(); ...; spawn(g); f() end
ℑ(C, P) ℑ(C, P) (Rt, v) ℑ(C, P) ℑ(C, P) C, otherwise
Figure 9. The second application of f can only be partially memoized up to the second send since only the first receive made by g is blocked in the global state.
Figure 5. The function ℑ yields the set of constraints C which are not satisfiable in program state P.
v2 or v3 on channels c1 and c2 would be included as well. For the sake of discussion, assume that the send of v2 by h was consumed by g and the send of v3 was paired with the receive in f when f() was originally executed. Consider the memoizability constraints built during the first call to f . The send constraint on f ’s application can be satisfied by matching it with the corresponding receive constraint associated with the application of g ; observe g() loops forever, consuming values on channels c1 and c2 . The receive constraint associated with f can be discharged if g receives the first send by h , and f receives the second. A schedule that orders the execution of f and g in this way, and additionally pairs i with a send operation on c2 in the let -body would allow the second call to f to fully leverage the memo information recorded in the first. Doing so would enable eliding the pure computation in f (abstracted by . . .) in its definition, performing only the effects defined by the communication actions (i.e., the send of v1 on c1 , and the receipt of v3 on c2 ).
let val (c1,c2) = (mkCh(),mkCh()) fun f () = (send(c1,v1); ...; recv(c2)) fun g () = (recv(c1); ...; recv(c2); g()) fun h () = (...; send(c2,v2); send(c2,v3); h()); fun i () = (recv(c2); i()) in spawn(g); spawn(h); spawn(i); f(); ...; send(c2, v4); ...; f() end
Figure 7. Determining if an application can fully leverage memo information may require examining an arbitrary number of possible thread interleavings. g() c1
v1
c1
f()
h()
i()
c2
c2
4.5
v2 c2
c2
As this example illustrates, utilizing memo information completely may require forcing a schedule that pairs communication actions in a specific way, making a solution that requires all constraints to be satisfied infeasible in practice. Hence, rule M EMO A PP allows evaluation to continue within an application that has already been memoized once a constraint cannot be matched. As a result, if during the second call to f , i indeed received v3 from h , the constraint associated with the recv operation in f would not be satisfied, and the rules would obligate the call to block on the recv , waiting for h or the main body to send a value on c2 . Nonetheless, the semantics as currently defined does have limitations. For example, the function ℑ does not examine future actions of threads and thus can only match a constraint with a thread if that thread is able to match the constraint in the current state. Hence, the rules do not allow leveraging memoization information for function calls involved in a producer/consumer relation. Consider the simple example given in Fig. 9. The second application of f can take advantage of memoized information only for the first send on channel c. This is because the global state in which constraints are checked only has the first recv made by g blocked on the channel. The second recv only occurs if the first is successfully paired with a corresponding send. Although in this simple example the second recv is guaranteed to occur, consider if g contained a branch which determined if g consumed a second value from the channel c. In general, constraints can only be matched against the current communication action of a thread. Secondly, exploiting memoization may lead to starvation since subsequent applications of the memoized call will be matched based on the constraints supplied by the initial call. Consider the example shown in Fig. 10. If the initial application of f pairs with the send performed by g, subsequent calls to f that use this memoized version will also pair with g since h produces different values. This leads to starvation of h. Although this behavior is certainly legal, one might reasonably expect a scheduler to interleave the sends of g and h.
c2
v3 c2
v4
Figure 8. The communication pattern of the code in Fig 7. Circles represent operations on channels. Gray circles are sends and white circles are receives. Double arrows represent communications that are captured as constraints during memoization. been satisfied, it is this return value that replaces the call in the current context (see the consequent of rule M EMO A PP). 4.4
Issues
Example
The program fragment shown in Fig. 7 applies functions f, g, h and i. The calls to g, h, and i are evaluated within separate threads of control, while the applications of f takes place in the original thread. These different threads communicate with one other over shared channels c1 and c2. The communication pattern is depicted in Fig. 8. Separate threads of control are shown as rectangles. Communication actions are represented as circles; gray for send actions and white for receives. The channel on which the communication action takes place is annotated on the circle and the value which is sent on the arrow. Double arrows represent communication actions for which constraints are generated. To determine whether the second call to f can be elided we must examine the constraints that would be added to the thread state of the threads in which these functions are applied. First, spawn constraints would be added to the main thread for the threads executing g, h, and i. Second, a send constraint followed by a receive constraint, modeling the exchange of values v1 and either
167
(MC H )
(MS PAWN ) C = ((Ch, l), ) l0 fresh C00 = C[l0 /l] θ,C θ0
C = ((Sp, e0 ), ) t0 fresh
θ,C θ0
MSp
MCh
/ t0 [e0 ]i, σ (t,C.C), Pkhθ, tC0 [E[λδ x.e (v)]]i, σ =⇒ (t,C), Pkhθ0 , tC0 [E[λδ x.e (v)]]ikh0,
(t,C.C), Pkhθ, tC0 [E[λδ x.e (v)]]i, σ =⇒ (t,C00 ), Pkhθ0 , tC0 [E[λδ x.e (v)]]i, σ
(MR ECV )
(MS END ) C = ((S, l, v), ) ts = hθ0 , t0 0 [E[λδ x.e (v)]]i tr = hθ, t[E0 [recv(l)]]i C θ0 ,C θ000 θ, ((R, l, v), E0 [recv(l)]) θ00 ts0 = hθ000 , t0 0 [E[λδ x.e (v)]]i tr0 = hθ00 , t[E0 [v]]i
C = ((R, l, v), ) ts = hθ, t[E[send(l, v)]]i tr = hθ0 , t0 0 [E0 [λδ x.e (v)]]i C θ0 ,C θ000 θ, ((S, l, v), E[send(l, v)]) θ00 ts0 = hθ00 , t[E[unit]]i tr0 = hθ000 , t0 0 [E0 [λδ x.e (v)]]i C
C
MCom
MCom
(t0 ,C.C), Pkts ktr , σ =⇒ (t0 ,C), Pkts0 ktr0 , σ
(t0 ,C.C), Pkts ktr , σ =⇒ (t0 ,C), Pkts0 ktr0 , σ
Figure 6. Constraint matching is defined by four rules. Communication constraints are matched with threads performing the opposite communication action of the constraint. straint and another on a receive constraint both of which match on the channel and value. In this case, the constraints on both sender and receiver can be safely discharged. This allows calls which attempt to use previously memoized constraints to match against constraints extant in other calls that also attempt to exploit memoized state.
let fun f() = recv(c) fun g() = send(c,1);g() fun h() = send(c,2);h() in spawn(g); spawn(h); f(); ...; f() end
Figure 10. Memoization of the function f can lead to the starvation of either of g or h depending on which value the original application of f consumed from channel c. 4.6
5.
Soundness
We can relate the states produced by memoized evaluation to the states constructed by the non-memoizing evaluator. To do so, we first define a transformation function T that transforms process states (and terms) defined under memo evaluation to process states (and terms) defined under non-memoized evaluation (see Fig. 12). Since memo evaluation stores evaluation contexts in θ they must be extracted and restored. This is done in the opposite order that they were pushed onto the stack θ since the top represents the most recent call. Functions currently in the process of utilizing memo information must be resumed from the expression captured in the first non-discharged constraint. Similarly threads which are currently paused must also be resumed. Our safety theorem ensures memoization does not yield states which could not be realized under non-memoized evaluation: Theorem[Safety] If
Schedule Aware Partial Memoization
To address the limitations in the previous section, we define two new symmetric rules to pause and resume memoization (see Fig. 11). Pausing memoization (rule PAUSE M EMO) is similar to the rule E ND M EMO in Fig. 4 except the failed constraints are not discarded and the thread context is not given an expression to evaluate. Instead the thread retains its log of currently unsatisfied constraints which prevents its further evaluation. This effectively pauses the evaluation of this thread but allows regular threads to continue normal evaluation. Notice we only pause a thread utilizing memo information once it has correctly discharged its constraints. We could envision an alternative definition which pauses non-deterministically on any constraint and moves the nondischarged constraints back to the thread context which holds unsatisfied constraints. For the sake of simplicity we opted for greedy semantics which favors the utilization of memoization. We can resume the paused thread, enabling it to discharge other constraints using the rule R ESUME M EMO, which begins constraint discharge anew for a paused thread. Thus, if a thread context has a set of constraints that were not previously satisfied and evaluation is not utilizing memo information, we can once again apply our ℑ function. Note that the use of memo information can be ended at any time (rule E ND M EMO can be applied instead of PAUSE M EMO). We can, therefore, change a thread in a paused state into a bona fide thread by first applying R ESUME M EMO. If ℑ does not indicate we can discharge any additional constraints, we simply apply the rule E ND M EMO. We also extend our evaluation rules to allow constraints to be matched against other constraints (rule MC OM). This is accomplished by matching constraints between two paused threads. Of course, it is possible that two threads, both of which were paused on a constraint that was not satisfiable may nonetheless satisfy one another. This happens when one thread is paused on a send con-
Pkhθ, t[E[λδ x.e (v)]]i, σ
MemS.β1 .MemE.β2
=⇒
P0 khθ0 , t[E[v0 ]]i, σ0
then there exists α1 , . . . , αn ∈ {App, Ch, Spn, Com} s.t. n 1 T (Pkhθ, t[E[λδ x.e (v)]]i) 7−→ . . . 7−→ T (P0 khθ0 , t[E[v0 ]]i)
α
α
2 The proof 2 is by induction on the length of β1 .MemE.β2 . Each of the elements comprising β1 .MemE.β2 corresponds to an action necessary to discharge previously recorded memoization constraints. We can show that every β step taken under memoization corresponds to some number of pure steps, and zero or one sideeffecting steps under non-memoized evaluation: zero steps for returns and memo actions (e.g. M EM S, M EM E, M EM P, and M EM R), and one step for core evaluation, and effectful actions (e.g., MC H, MS PAWN, MR ECV, MS END, and MC OM). Since a function which 2 see
http://www.cs.purdue.edu/homes/lziarek/memoproof.pdf for full details.
168
(PAUSE M EMO ) MemP
/ P, σ =⇒ P, σ (t, 0), (R ESUME M EMO )
(MC OM )
ℑ(C, P) = C0
C = ((S, l, v), ) C0 = ((R, l, v), ) ts = hθ, tC.C [λδ x.e (v)]i tr = hθ0 , t0 0 0 [λδ1 x.e0 (v0 )]i C .C θ,C θ00 θ0 ,C0 θ000 00 000 ts0 = hθ , tC [λδ x.e (v)]i tr0 = hθ , t0 0 [λδ1 x.e0 (v0 )]i
C = C00 .C0
C
MemR
MCom
Pkhθ, tC [E[λδ x.e (v)]]i, σ =⇒ (t,C00 ), Pkhθ, tC0 [E[λδ x.e (v)]]i, σ
Pkts ktr , σ =⇒ Pkts0 ktr0 , σ
Figure 11. Schedule Aware Partial Memoization.
T ((t,C), Pkhθ, tC0 [E[λδ x.e (v)]]i) T ((P1 kP2 )) T (hθ, t[e]i) T (hθ, t( ,e0 ).C [e]i) T (λδ x.e) T (e1 (e2 )) T (spawn(e)) T (send(e1 , e2 )) T (recv(e))
= = = = = = = = =
e
T (Pkhθ, tC.C0 [E[λδ x.e (v)]]i) T (P1 )kT (P2 ) t[T (En [. . . E1 [e]])] θi = (δi , vi , Ei ,Ci ) ∈ θ t[T (En [. . . E1 [e0 ]])] θi = (δi , vi , Ei ,Ci ) ∈ θ λ x.e
T (e1 )(T (e2 )) spawn(T (e)) send(T (e1 ), T (e2 )) recv(T (e)) otherwise
Figure 12. T defines an erasure property on program states. The first four rules remove memo information and restore evaluation contexts. 6.2
is utilizing memoized information does not execute pure code (rule A PP under 7−→ ), it may correspond to a number of A PP transitions under 7−→ .
6.
Our parallel implementation of CML is based on Reppy’s parallel model of CML (Reppy and Xiao 2008). We utilize low level locks implemented with compare and swap to provide guarded access to channels. Whenever a thread wishes to perform an action on a channel, it must first acquire the lock associated with the channel. Since a given thread may only utilize one channel at a time, there is no possibility of deadlock.
Implementation
Our implementation is incorporated within Multi-MLton, an extension of MLton (MLton), a whole-program optimizing compiler for Standard ML, that provides support for parallel thread execution. The main extensions to Multi-MLton to support partial memoization involve insertion of read and write barriers to track accesses and updates of references, barriers to monitor function arguments and return values, and hooks to the Concurrent ML library to monitor channel based communication. 6.1
Parallel CML and hooks
The underlying CML library was also modified to make memoization efficient. The bulk of the changes were hooks to monitor channel communication and spawns, additional channel queues to support constraint matching on synchronous operations, and to log successful communication (including selective communication and complex composed events). The constraint matching engine required a modification to the channel structure. Each channel is augmented with two additional queues to hold send and receive constraints. When a constraint is being tested for satisfiability, the opposite queue is first checked (e.g. a send constraint would check the receive constraint queue). If no match is found, the regular queues are checked for satisfiability. If the constraint cannot be satisfied immediately it is added to the appropriate queue.
Multi-MLton
To support parallel execution, we modified the MLton compiler to support parallel threads. A POSIX pthread executes on each processor. Each pthread manages a lightweight Multi-MLton thread queue. Each pthread switches between lightweight MLton threads on its queue when it is preempted. Pthreads are spawned and managed by Multi-MLton’s runtime. Currently, our implementation does not support migration of Multi-MLton threads to different thread queues.
6.3
The underlying garbage collector also supports parallel allocation. Associated with every processor is a private memory region used by threads it executes; allocation within this region requires no global synchronization. These regions are dynamic and growable. All pthreads must synchronize when garbage collection is triggered. Data shared by threads found on different processors are copied to a shared memory region that requires synchronization to access.
Supporting Memoization
Any SML function can be annotated as a candidate for memoization. For such annotated functions, its arguments and return values at different call-sites, the communication it performs, and information about the threads it spawns are recorded in a memo table. Memoization information is logged through hooks to the CML runtime and stored by the underlying client code. In addition, to support partial memoization, the continuations of logged communication events are also saved.
169
Our memoization implementation extended CML channels to be aware of memoization constraints. Each channel structure contained a queue of constraints waiting to be solved on the channel. Because it will not be readily apparent if a memoized version of a CML function can be utilized at a call site, we delay a function application to see if its constraints can be matched; these constraints must be satisfied in the order in which they were generated. Constraint matching can certainly fail on a receive constraint. A receive constraint obligates a receive operation to read a specific value from a channel. Since channel communication is blocking, a receive constraint that is being matched can choose from all values whose senders are currently blocked on the channel. This does not violate the semantics of CML since the values blocked on a channel cannot be dependent on one another; in other words, a schedule must exist where the matched communication occurs prior to the first value blocked on the channel. Unlike a receive constraint, a send constraint can only fail if there are (a) no matching receive constraints on the sending channel that expect the value being sent, or (b) no receive operations on that same channel. A CML receive operation (not receive constraint) is ambivalent to the value it removes from a channel; thus, any receive on a matching channel will satisfy a send constraint. If no receives or sends are enqueued on a constraint’s target channel, a memoized execution of the function will block. Therefore, failure to fully discharge constraints by stalling memoization on a presumed unsatisfiable constraint does not compromise global progress. This observation is critical to keeping memoization overheads low. Our memoization technique relies on efficient equality tests, and approximate equality on reals and functions; the latter is modeled as closure equality. Memoization data is discarded during garbage collection. This prevents unnecessary build up of memoization meta-data during execution. As a heuristic, we also enforce an upper bound for the amount of memo data stored for each function, and the space that each memo entry can take. A function that generates a set of constraints whose size exceeds the memo entry space bound is not memoized. For each memoized function, we store a list of memo meta-data. When the length of the list reaches the upper limit but new memo data is acquired upon an application of the function to previously unseen arguments, one entry from the list is removed at random. 6.4
to the next worker thread. The result defines an approximation of n clusters (where n is the number of workers) of size k (points that compose the cluster). STMBench7 (Guerraoui et al. 2007), is a comprehensive, tunable multi-threaded benchmark designed to compare different STM implementations and designs. Based on the well-known 007 database benchmark (Carey et al. 1993), STMBench7 simulates data storage and access patterns of CAD/CAM applications that operate over complex geometric structures. At its core, STMBench7 builds a tree of assemblies whose leaves contain bags of components; these components have a highly connected graph of atomic parts and design documents. Indices allow components, parts, and documents to be accessed via their properties and IDs. Traversals of this graph can begin from the assembly root or any index and sometimes manipulate multiple pieces of data. STMBench7 was originally written in Java. Our port, besides translating the assembly tree to use a CML-based server abstraction (as discussed in Section 3), also involved building an STM implementation to support atomic sections, loosely based on the techniques described in (Saha et al. 2006; Adl-Tabatabai et al. 2006). All nodes in the complex assembly structure and atomic parts graph are represented as servers with one receiving channel and handles to all other adjacent nodes. Handles to other nodes are simply the channels themselves. Each server thread waits for a message to be received, performs the requested computation, and then asynchronously sends the subsequent part of the traversal to the next node. A transaction can thus be implemented as a series of communications with various server nodes. Swerve is a web server written entirely in CML. It consists of a collection of modules that communicate using CML’s messagepassing operations. There are three critical modules of interest: (a) a listener that processes incoming requests; (b) a file processor that handles access to the underlying file system; and, (c) a timeout manager that regulates the amount of time allocated to serve a request. There is a dedicated listener for every distinct client, and each request received by a listener is delegated to a server thread responsible for managing that request. Requested files are broken into chunks and packaged as messages sent back to the client.
7.
Results
Our benchmarks were executed on a 16-way AMD Opteron 865 server with 8 processors, each containing two symmetric cores, and 32 GB of total memory, with each CPU having its own local memory of 4 GB. Access to non-local memory is mediated by a hyper-transport layer that coordinates memory requests between processors. To measure the effectiveness of our memoization technique, we executed two configurations (one memoized, and the other non-memoized) of our k-clustering algorithm, STMBench7, and Swerve, and measured overheads and performance by averaging results over ten executions. Non-memoized executions utilized a clean version of Multi-MLton without memoization hooks and barriers. The k-clustering algorithm utilizes memoization to avoid redundant computations based on previously witnessed points as well as redundant computations of clusters. For STMBench7 the nonmemoized configuration uses our STM implementation without any memoization where as the memoized configuration implements partial memoization of aborted transactions. Swerve uses memoization to avoid reading and processing previously requested files from disk. For k-clustering, we computed 16 clusters of size 60 out of a stream of 10K randomly generated points. This resulted in the creation of 16 workers threads, one stream generating thread, and a
Benchmarks
We examined three benchmarks to measure the effectiveness of partial memoization in a parallel setting. The first benchmark is a streaming algorithm for approximating a k-clustering of points on a geometric plane. The second is a port of the STMBench7 benchmark (Guerraoui et al. 2007). STMBench7 utilizes channel based communication instead of shared memory and bears resemblance to the red-black tree program presented in Section 3. The third is Swerve (Ziarek et al. 2006), a highly-tuned webserver written in CML. Similar to most streaming algorithms (Matthew Mccutchen and Khuller 2008), a k-clustering application defines a number of worker threads connected in a pipeline fashion. Each worker maintains a cluster of points that sit on a geometric plane. A stream generator creates a randomized data stream of points. A point is passed to the first worker in the pipeline. The worker computes a convex hull of its cluster to determine if a smaller cluster could be constructed from the newly received point. If the new point results in a smaller cluster, the outlier point from the original cluster is passed to the next worker thread. On the other hand, if the received point does not alter the configuration of the cluster, it is passed on
170
sink thread which aggregates the computation results. STMBench7 was executed on a graph in which there were approximately 280k complex assemblies and 140k assemblies whose bags referenced one of 100 components; by default, each component contained a parts graph of 100 nodes. STMBench7 creates a number of threads proportional to the number of nodes in the underlying data structure; this is roughly 400K threads for our configuration. Our experiments on Swerve were executed using the default server configuration and were exercised using HTTPerf, a well known tool for measuring webserver performance.
In Swerve, we observe an increase in performance correlated to the size of the file being requested by HTTPerf (see Fig. 13(c)); performance gains are capped at roughly 80% for file sizes greater than 8 MB. For each requested file, we build constraints corresponding to the file chunks read from disk. As long as no errors are encountered, the Swerve file processor sends the file chunks to be processed into HTTP packets by another module. After each chunk has been read from disk the file processor polls other modules for timeouts and other error conditions. If an error is encountered, subsequent file processing is stopped and the request is terminated. Partial memoization is particularly well suited for Swerve’s file processing semantics because control is reverted to the error handling mechanism precisely at the point an error is detected. This corresponds to a failed constraint.
The benchmarks represent three very different programming models – pipeline stream-based parallelism (k-clustering), dynamically established communication links (Swerve), and software transactions (STM-Bench7), and leverage different executions models – k-clustering makes use of long-lived worker-threads while STMBench7 utilizes many lightweight server threads. Swerve utilizes both lightweight server threads in conjunction with long-lived worker threads. Each run of the benchmarks have execution times that range between 1 and 3 minutes.
For all benchmarks, memory overheads are proportional to cache sizes and averaged roughly 15% for caches of size eight. The cache size defines the number of different memoized calls for a function maintined; thus a cache size of eight means that every memoized function records effects for eight different arguments.
For k-clustering we varied the number of repeated points generated by the stream. Configurations in which there is a high degree of repeated points offer the best performance gain (see Fig. 13(b)). For example, an input in which 50% of the input points are repeated yields roughly 50% performance gain. However, we also observe roughly 17% performance improvement even when all points are randomized. This is because the cluster’s convex hull shrinks as the points which comprise the cluster become geometrically compact. Thus, as the convex hull of the cluster shrinks, the likelihood of a random point being contained within the convex hull of the cluster is reduced. Memoization can take advantage of this phenomenon by avoiding recomputation of the convex hull as soon as it can be determined that the input point resides outside the current cluster. Although we do not envision workloads that have high degrees of repeatability, memoization nonetheless leads to a 30% performance gain on a workload in which only 10% of the inputs are repeated.
8.
Related Work
Memoization, or function caching (Liu and Teitelbaum 1995; Pugh 1988; Heydon et al. 2000; Swadi et al. 2006), is a well understood method to reduce the overheads of redundant function execution. Memoization of functions in a concurrent setting is significantly more difficult and usually highly constrained (Pickett and Verbrugge 2005). We are unaware of any existing techniques or implementations that apply memoization to the problem of reducing transaction overheads in languages that support selective communication and dynamic thread creation. Our approach also bears resemblance to the procedure summary construction for concurrent programs (Qadeer et al. 2004). However, these approaches tend to be based on a static analysis (e.g., the cited reference leverages procedure summaries to improve the efficiency of model checking) and thus are obligated to make use of memoization greedily. Because our motivation is quite different, our approach can consider lazy alternatives, ones that leverage synchronization points to stall memo information use, resulting in potentially improved runtime benefit.
In STMbench7, the utility of memoization is closely correlated to the number and frequency of aborted transactions. Our tests varied the read-only/read-write ratio (see Fig. 13(a)) within transactions. Only transactions that modify values can cause aborts. Thus, an execution where all transactions are read-only cannot be accelerated, but one in which transactions can frequently abort (because of conflicts due to concurrent writes) offer potential opportunities for memoization. Thus, the cost to support memoization is seen when there are 100% read-only transactions; in this case, the overhead is roughly 11%. These overheads arise because of the cost to capture memo information (storing arguments, continuations, etc) and the cost associated with trying to utilize the memo information (discharging constraints).
Recently software transactions (Harris and Fraser 2003; Saha et al. 2006) have emerged as a new method to safely control concurrent execution. There has also been work on applying these techniques to a functional programming setting (Harris et al. 2005; Ringenburg and Grossman 2005). These proposals usually rely on an optimistic concurrency control model that checks for serializability violations prior to committing the transaction, aborting when a violation is detected. Our benchmark results suggest that partial memoization can help reduce the overheads of aborting optimistic transactions.
Notice that as the number of transactions which perform modifications to the underlying data-structure increases so do memoization gains. For example, when the percentage of read-only transactions is 60%, we see an 18% improvement in runtime performance compared to a non-memoizing implementation for STMBench7.
Self adjusting mechanisms (Acar et al. 2008; Ley-Wild et al. 2008) leverage memoization along with change propagation to automatically alter a program’s execution to a change of inputs given an existing execution run. Memoization is used to identify parts of the program which have not changed from the previous execution while change propagation is harnessed to install changed values where memoization cannot be applied. There has also been recent work on using change propagation in a parallel setting (Hammer et al. 2007). The programming model assumes fork/join parallelism, and is therefore not suitable for the kinds of contexts we consider. We believe our memoization technique is synergistic with current self-adjusting techniques and can be leveraged along with self-adjusting computation to create self-adjusting programs which utilize message passing.
We expected to see roughly a linear trend correlated to the increase in transactions which perform an update. However, we noticed that performance degrades about 10% from a read/write ratio of 20 to a read/write ratio of zero. This phenomenon occurs because memoized transactions are more likely to complete on their first try when there are fewer modifications to the structure. Since a non-memoized transaction requires longer to complete, it has a greater chance of aborting when there are frequent updates. This difference becomes muted as the number of changes to the data structure increase.
171
(a)
(b)
(c)
Figure 13. Normalized runtime % speedup for k-clustering, STMBench7, and Swerve benchmarks of memoized evaluation compared to non-memoized execution. Our technique also shares some similarity with transactional events (Donnelly and Fluet 2006; Effinger-Dean et al. 2008). Transactional events explore a state space of possible communications finding matching communications to ensure atomicity of a collection of actions. To do so transactional events require arbitrary lookahead in evaluation to determine if a complex composed event can commit. Partial memoization also explores potential matching communication actions to satisfy memoization constraints. However, partial memoization avoids the need for arbitrary lookahead – failure to discharge memoization constraints simply causes execution to proceed as normal.
Tim Harris, Simon Marlow, Simon Peyton-Jones, and Maurice Herlihy. Composable Memory Transactions. In Proceedings of the ACM Conference on Principles and Practice of Parallel Programming, pages 48–60, 2005. Allan Heydon, Roy Levin, and Yuan Yu. Caching function calls using precise dependencies. In PLDI, pages 311–320, 2000. Ruy Ley-Wild, Matthew Fluet, and Umut A. Acar. Compiling self-adjusting programs with continuations. In ICFP, pages 321–334, 2008. Yanhong A. Liu and Tim Teitelbaum. Caching Intermediate Results for Program Improvement. In PEPM, pages 190–201, 1995. Richard Matthew Mccutchen and Samir Khuller. Streaming algorithms for k-center clustering with outliers and with anonymity. In APPROX ’08 / RANDOM ’08, pages 165–178, 2008.
Acknowledgements
MLton. http://www.mlton.org.
Thanks to Matthew Fluet and Dan Spoonhower for their help in the design and development of Multi-MLton. This work is supported by the National Science Foundation under grants CCF-0701832 and CCF-0811631, and an Intel graduate fellowship.
Christopher J. F. Pickett and Clark Verbrugge. Software Thread Level Speculation for the Java Language and Virtual Machine Environment. In Proceedings of the International Workshop on Languages and Compilers for Parallel Computing, 2005. W. Pugh and T. Teitelbaum. Incremental Computation via Function Caching. In POPL, pages 315–328, 1989.
References Umut A. Acar, Guy E. Blelloch, and Robert Harper. Selective Memoization. In POPL, pages 14–25, 2003.
William Pugh. An Improved Replacement Strategy for Function Caching. In LFP, pages 269–276, 1988.
Umut A. Acar, Amal Ahmed, and Matthias Blume. Imperative SelfAdjusting Computation. In POPL, pages 309–322, 2008.
Shaz Qadeer, Sriram K. Rajamani, and Jakob Rehof. Summarizing procedures in concurrent programs. In POPL, pages 245–255, 2004.
Ali-Reza Adl-Tabatabai, Brian T. Lewis, Vijay Menon, Brian R. Murphy, Bratin Saha, and Tatiana Shpeisman. Compiler and Runtime Support for Efficient Software Transactional Memory. In PLDI, pages 26–37, 2006.
John Reppy and Yingqi Xiao. Towards a Parallel Implementation of Concurrent ML. In DAMP 2008, January 2008. John H. Reppy. Concurrent Programming in ML. Cambridge University Press, 1999.
Michael J. Carey, David J. DeWitt, and Jeffrey F. Naughton. The 007 benchmark. SIGMOD Record, 22(2):12–21, 1993.
Michael F. Ringenburg and Dan Grossman. AtomCaml: First-Class Atomicity via Rollback. In Proceedings of the ACM SIGPLAN International Conference on Functional Programming, pages 92–104, 2005.
Kevin Donnelly and Matthew Fluet. Transactional Events. In ICFP, pages 124–135, 2006.
Bratin Saha, Ali-Reza Adl-Tabatabai, Richard L. Hudson, Chi Cao Minh, and Benjamin Hertzberg. McRT-STM: a High-Performance Software Transactional Memory system for a Multi-Core Runtime. In PPoPP, pages 187–197, 2006.
Laura Effinger-Dean, Matthew Kehrt, and Dan Grossman. Transactional events for ml. In ICFP ’08, pages 103–114, 2008. ISBN 978-1-59593919-7. Michael I. Gordon, William Thies, and Saman Amarasinghe. Exploiting Coarse-Grained Task, Data, and Pipeline Parallelism in Stream Programs. In ASPLOS-XII, pages 151–162, 2006.
Kedar Swadi, Walid Taha, Oleg Kiselyov, and Emir Pasalic. A Monadic Approach for Avoiding Code Duplication When Staging Memoized Functions. In PEPM, pages 160–169, 2006.
Rachid Guerraoui, Michal Kapalka, and Jan Vitek. STMBench7: A Benchmark for Software Transactional Memory. In EuroSys, pages 315–324, 2007.
Lukasz Ziarek, Philip Schatz, and Suresh Jagannathan. Stabilizers: A Modular Checkpointing Abstraction for Concurrent Functional Programs. In ACM International Conference on Functional Programming, pages 136– 147, 2006.
Matthew Hammer, Umut A. Acar, Mohan Rajagopalan, and Anwar Ghuloum. A Proposal for Parallel Self-Adjusting Computation. In Workshop on Declarative Aspects of Multicore Programming, 2007. Tim Harris and Keir Fraser. Language support for lightweight transactions. In OOPSLA, pages 388–402, 2003.
172
Free Theorems Involving Type Constructor Classes Functional Pearl Janis Voigtl¨ander Institut f¨ur Theoretische Informatik Technische Universit¨at Dresden 01062 Dresden, Germany
[email protected]
Abstract
Let us consider a more specific example, say functions of the type Monad µ ⇒ [µ Int] → µ Int. Here are some:1
Free theorems are a charm, allowing the derivation of useful statements about programs from their (polymorphic) types alone. We show how to reap such theorems not only from polymorphism over ordinary types, but also from polymorphism over type constructors restricted by class constraints. Our prime application area is that of monads, which form the probably most popular type constructor class of Haskell. To demonstrate the broader scope, we also deal with a transparent way of introducing difference lists into a program, endowed with a neat and general correctness proof.
f1 = head f2 ms = sequence ms >>= return ◦ sum f3 = f2 ◦ reverse f4 [ ] = return 0 f4 (m : ms) = do i ← m let l = length ms if i > l then return (i + l) else f4 (drop i ms)
Categories and Subject Descriptors F.3.1 [Logics and Meanings of Programs]: Specifying and Verifying and Reasoning about Programs—Invariants; D.1.1 [Programming Techniques]: Applicative (Functional) Programming; D.3.3 [Programming Languages]: Language Constructs and Features—Polymorphism General Terms Keywords
1.
As we see, there is quite a variety of such functions. There can be simple selection of one of the monadic computations from the input list (as in f1 ), there can be sequencing of these monadic computations (in any order) and some action on the encapsulated values (as in f2 and f3 ), and the behaviour, in particular the choice which of the computations from the input list are actually performed, can even depend on the encapsulated values themselves (as in f4 , made a bit artificial here). Further possibilities are that some of the monadic computations from the input list are performed repeatedly, and so forth. But still, all these functions also have something in common. They can only combine whatever monadic computations, and associated effects, they encounter in their input lists, but they cannot introduce new effects of any concrete monad, not even of the one they are actually operating on in a particular application instance. This limitation is determined by the function type. For if an f were, on and of its own, to cause any additional effect to happen, be it by writing to the output, by introducing additional branching in the nondeterminism monad, or whatever, then it would immediately fail to get the above type parametric over µ. In a language like Haskell, should not we be able to profit from this kind of abstraction for reasoning purposes? If so, what kind of insights can we hope for? One thing to expect is that in the special case when the concrete computations in an input list passed to an f :: Monad µ ⇒ [µ Int] → µ Int correspond to pure values (e.g., are values of type IO Int that do not perform any actual input or output), then the same should hold of f ’s result for that input list. This statement is quite intuitive from the above observation about f being unable to cause new effects on
Languages, Verification
relational parametricity
Introduction
One of the strengths of functional languages like Haskell is an expressive type system. And yet, some of the benefits this strength should hold for reasoning about programs seem not to be realised to full extent. For example, Haskell uses monads (Moggi 1991) to structure programs by separating concerns (Wadler 1992; Liang et al. 1995) and to safely mingle pure and impure computations (Peyton Jones and Wadler 1993; Launchbury and Peyton Jones 1995). A lot of code can be kept independent of a concrete choice of monad. This observation pertains to functions from the Prelude (Haskell’s standard library) like sequence :: Monad µ ⇒ [µ α] → µ [α] , but also to many user-defined functions. Such abstraction is certainly a boon for modularity of programs. But also for reasoning?
Permission to make digital or hard copies of all or part of this work for personal or classroom use is granted without fee provided that copies are not made or distributed for profit or commercial advantage and that copies bear this notice and the full citation on the first page. To copy otherwise, to republish, to post on servers or to redistribute to lists, requires prior specific permission and/or a fee. ICFP’09, August 31–September 2, 2009, Edinburgh, Scotland, UK. c 2009 ACM 978-1-60558-332-7/09/08. . . $5.00 Copyright
1 The
functions head, sum, reverse, length, and drop are all from the Prelude. Their general types and explanation can be found via Hoogle (http://haskell.org/hoogle). The notation ◦ is for function composition, while >>= and do are two different syntaxes for performing computations in a monad one after the other. Finally, return embeds pure values into a monad.
173
can only ever contain elements from the input list. For the function, not knowing the element type of the lists it operates over, cannot possibly make up new elements of any concrete type to put into the output, such as 42 or True, or even id , because then f would immediately fail to have the general type [α] → [α].2
its own. But what about more interesting statements, for example the preservation of certain invariants? Say we pass to f a list of stateful computations and we happen to know that they do depend on, but do not alter (a certain part of) the state. Is this property preserved throughout the evaluation of a given f ? Or say the effect encapsulated in f ’s input list is nondeterminism but we would like to simplify the program by restricting the computation to a deterministically chosen representative from each nondeterministic manifold. Under what conditions, and for which kind of representativeselection functions, is this simplification safe and does not lead to problems like a collapse of an erstwhile nonempty manifold to an empty one from which no representative can be chosen at all? One could go and study these questions for particular functions like the f1 to f4 given further above. But instead we would like to answer them for any function of type Monad µ ⇒ [µ Int] → µ Int in general, without consulting particular function definitions. And we would not like to restrict to the two or three scenarios depicted in the previous paragraph. Rather, we want to explore more abstract settings of which statements like the ones in question above can be seen, and dealt with, as particular instances. And, of course, we prefer a generic methodology that applies equally well to other types than the specific one of f considered so far in this introduction. These aims are not arbitrary or far-fetched. Precedent has been set with the theorems obtained for free by Wadler (1989) from relational parametricity (Reynolds 1983). Derivation of such free theorems, too, is a methodology that applies not only to a single type, works independently of particular function definitions, and applies to a diverse range of scenarios: from simple algebraic laws to powerful program transformations (Gill et al. 1993), to meta-theorems about whole classes of algorithms (Voigtl¨ander 2008b), to specific applications in software engineering and databases (Voigtl¨ander 2009). Unsurprisingly then, we do build on Reynolds’ and Wadler’s work. Of course, the framework that is usually considered when free theorems are derived needs to be extended to deal with types like Monad µ ⇒ . . . . But the ideas needed to do so are there for the taking. Indeed, both relational parametricity extended for polymorphism over type constructors rather than over ordinary types only, as well as relational parametricity extended to take class constraints into account, are in the folklore. However, these two strands of possible extension have not been combined before, and not been used as we do. Since we are mostly interested in demonstrating the prospects gained from that combination, we refrain here from developing the folklore into a full-fledged formal apparatus that would stand to blur the intuitive ideas. This is not an overly theoretical paper. Also on purpose, we do not consider Haskell intricacies, like those studied by Johann and Voigtl¨ander (2004) and Stenger and Voigtl¨ander (2009), that do affect relational parametricity but in a way orthogonal to what is of interest here. Instead, we stay with Reynolds’ and Wadler’s simple model (but consider the extension to general recursion in Appendix C). For the sake of accessibility, we also stay close to Wadler’s notation.
2.
So for any input list l (over any element type) the output list f l consists solely of elements from l. But how can f decide which elements from l to propagate to the output list, and in which order and multiplicity? The answer is that such decisions can only be made based on the input list l. For in a pure functional language f has no access to any global state or other context based on which to decide. It cannot, for example, consult the user in any way about what to do. And the means by which to make decisions based on l are limited as well. In particular, decisions cannot possibly depend on any specifics of the elements of l. For the function is ignorant of the element type, and so is prevented from analysing list elements in any way (be it by patternmatching, comparison operations, or whatever). In fact, the only means for f to drive its decision-making is to inspect the length of l, because that is the only element-independent “information content” of a list. So for any pair of lists l and l0 of same length (but possibly over different element types) the lists f l and f l0 are formed by making the same position-wise selections of elements from l and l0 , respectively. Now consider the following standard Haskell function: map :: (α → β) → [α] → [β] map g [ ] = [] map g (a : as) = (g a) : (map g as) Clearly, map g for any g preserves the lengths of lists. So if l0 = map g l, then f l and f l0 are of the same length and contain, at each position, position-wise exactly corresponding elements from l and l0 , respectively. Since, moreover, any two position-wise corresponding elements, one from l and one from l0 = map g l, are related by the latter being the g-image of the former, we have that at each position f l0 contains the g-image of the element at the same position in f l. So for any list l and (type-appropriate) function g, we have f (map g l) = map g (f l). Note that during the reasoning leading up to that statement we did not (need to) consider the actual definition of f at all. The methodology of deriving free theorems a` la Wadler (1989) is a way to obtain statements of this flavour for arbitrary function types, and in a more disciplined (and provably sound) manner than the mere handwaving performed above. The key to doing so is to interpret types as relations. For example, given the type signature f :: [α] → [α], we take the type and replace every quantification over type variables, including implicit quantification (note that the type [α] → [α], by Haskell convention, really means ∀α. [α] → [α]), by quantification over relation variables: ∀R. [R] → [R]. Then, there is a systematic way of reading such expressions over relations as relations themselves. In particular,
Free Theorems, in Full Beauty
So what is the deal with free theorems? Why should it be possible to derive statements about a function’s behaviour from its type alone? Maybe it is best to start with a concrete example. Consider the type signature
• base types like Int are read as identity relations, • for relations R and S, we have
f :: [α] → [α] .
R → S = {(f, g) | ∀(a, b) ∈ R. (f a, g b) ∈ S} ,
What does it tell us about the function f ? For sure that it takes lists as input and produces lists as output. But we also see that f is polymorphic, due to the type variable α, and so must work for lists over arbitrary element types. How, then, can elements for the output list come into existence? The answer is that the output list
and 2 The
situation is more complicated in the presence of general recursion. For further discussion, see Appendix C.
174
• for types τ and τ 0 with at most one free variable, say α, and
Sheard (1996), Kuˇcan (1997), Takeuti (2001), and Vytiniotis and Weirich (2009). Regarding class constraints, Wadler (1989, Section 3.4) directs the way by explaining how to treat the type class Eq in the context of deriving free theorems. The idea is to simply restrict the relations chosen as interpretation for type variables that are subject to a class constraint. Clearly, only relations between types that are instances of the class under consideration are allowed. Further restrictions are obtained from the respective class declaration. Namely, the restrictions must precisely ensure that every class method (seen as a new constant in the language) is related to itself by the relational interpretation of its type. This relatedness then guarantees that the overall result (i.e., that the relational interpretation of every closed type is an identity relation) stays intact (Mitchell and Meyer 1985). The same approach immediately applies to type constructor classes as well. Consider, for example, the Monad class declaration:
a function F on relations such that every relation R between closed types τ1 and τ2 , denoted R : τ1 ⇔ τ2 , is mapped to a relation F R : τ [τ1 /α] ⇔ τ 0 [τ2 /α], we have ∀R. F R = {(u, v) | ∀τ1 , τ2 , R : τ1 ⇔ τ2 . (uτ1 , vτ2 ) ∈ F R} (Here, uτ1 :: τ [τ1 /α] is the instantiation of u :: ∀α. τ to the type τ1 , and similarly for vτ2 . In what follows, we will always leave type instantiation implicit.) Also, every fixed type constructor is read as an appropriate construction on relations. For example, the list type constructor maps every relation R : τ1 ⇔ τ2 to the relation [R] : [τ1 ] ⇔ [τ2 ] defined by (the least fixpoint of) [R] = {([ ], [ ])}∪{(a : as, b : bs) | (a, b) ∈ R, (as, bs) ∈ [R]} , the Maybe type constructor maps every relation R : τ1 ⇔ τ2 to the relation Maybe R : Maybe τ1 ⇔ Maybe τ2 defined by
class Monad µ where return :: α → µ α (>>=) :: µ α → (α → µ β) → µ β
Maybe R = {(Nothing, Nothing)} ∪ {(Just a, Just b) | (a, b) ∈ R} ,
Since the type of return is ∀µ. Monad µ ⇒ (∀α. α → µ α), we expect that (return, return) ∈ ∀F. Monad F ⇒ (∀R. R → F R), and similarly for >>=. The constraint “Monad F” on a relational action is now defined in precisely such a way that both conditions will be fulfilled.
and similarly for other user-definable types. The key insight of relational parametricity a` la Reynolds (1983) now is that any expression over relations that can be built as above, by interpreting a closed type, denotes the identity relation on that type. For the above example, this insight means that any f :: ∀α. [α] → [α] satisfies (f, f ) ∈ ∀R. [R] → [R], which by unfolding some of the above definitions is equivalent to having for every τ1 , τ2 , R : τ1 ⇔ τ2 , l :: [τ1 ], and l0 :: [τ2 ] that (l, l0 ) ∈ [R] implies (f l, f l0 ) ∈ [R], or, specialised to the function level (R 7→ g, and thus [R] 7→ map g), for every g :: τ1 → τ2 and l :: [τ1 ] that f (map g l) = map g (f l). This proof finally provides the formal counterpart to the intuitive reasoning earlier in this section. And the development is algorithmic enough that it can be performed automatically. Indeed, an online free theorems generator (B¨ohme 2007) is accessible at our homepage (http://linux.tcs.inf.tu-dresden.de/~voigt/ft/).
3.
Definition 1. Let κ1 and κ2 be type constructors that are instances of Monad and let F : κ1 ⇔ κ2 be a relational action. If • (return κ1 , return κ2 ) ∈ ∀R. R → F R and • ((>>=κ1 ), (>>=κ2 )) ∈ ∀R. ∀S. F R → ((R → F S) →
F S), then F is called a Monad-action.3 (While we have decided to generally leave type instantiation implicit, we explicitly retain instantiation of type constructors in what follows, except for some examples.) For example, given the following standard Monad instance definitions: instance Monad Maybe where return a = Just a Nothing >>= k = Nothing Just a >>= k = k a
The Extension to Type Constructor Classes
We now want to deal with two new aspects: with quantification over type constructor variables (rather than just over type variables) and with class constraints (Wadler and Blott 1989). For both aspects, the required extensions to the interpretation of types as relations appear to be folklore, but have seldom been spelled out and have not been put to use before as we do in this paper. Regarding quantification over type constructor variables, the necessary adaptation is as follows. Just as free type variables are interpreted as relations between arbitrarily chosen closed types (and then quantified over via relation variables), free type constructor variables are interpreted as functions on such relations tied to arbitrarily chosen type constructors. Formally, let κ1 and κ2 be type constructors (of kind ∗ → ∗). A relational action for them, denoted F : κ1 ⇔ κ2 , is a function F on relations between closed types such that every R : τ1 ⇔ τ2 (for arbitrary τ1 and τ2 ) is mapped to an F R : κ1 τ1 ⇔ κ2 τ2 . For example, the function F that maps every R : τ1 ⇔ τ2 to
instance Monad [] where return a = [a] as >>= k = concat (map k as) the relational action F : Maybe ⇔ [] given above is not a Monadaction, because it is not the case that ((>>=Maybe ), (>>=[] )) ∈ ∀R. ∀S. F R → ((R → F S) → F S). To see this, consider R = S = id Int , m1 = Just 1 , m2 = [1, 2] , k1 = λi → if i > 1 then Just i else Nothing , and k2 = λi → reverse [2..i] . Clearly, (m1 , m2 ) ∈ F id Int and (k1 , k2 ) ∈ id Int → F id Int , but (m1 >>=Maybe k1 , m2 >>=[] k2 ) = (Nothing, [2]) ∈ / F id Int . On the other hand, the relational action F 0 : Maybe ⇔ [] with
F R = {(Nothing, [ ])} ∪ {(Just a, b : bs) | (a, b) ∈ R, bs :: [τ2 ]}
F 0 R = {(Nothing, [ ])} ∪ {(Just a, [b]) | (a, b) ∈ R}
is a relational action F : Maybe ⇔ []. The relational interpretation of a type quantifying over a type constructor variable is now performed in an analogous way as explained for quantification over type (and then, relation) variables above. In different formulations and detail, the same basic idea is mentioned or used by Fegaras and
is a Monad-action. 3 It
is worth noting that “dictionary translation” (Wadler and Blott 1989) would be an alternative way of motivating this definition.
175
inside functions of that type. Just consider the function f2 from the introduction. During that function’s computation, the monadic bind operation (>>=) is used to combine a µ-encapsulated integer list (viz., sequence ms :: µ [Int]) with a function to a µ-encapsulated single integer (viz., return ◦ sum :: [Int] → µ Int). Clearly, the same or similarly modular code could not have been written at type f2 :: IntMonad µ ⇒ [µ] → µ, because there is no way to provide a function like sequence for the IntMonad class (or any single monomorphised class), not even when we are content with making sequence less flexible by fixing the α in its current type to be Int. So again, proving results about all functions of type f :: Monad µ ⇒ [µ Int] → µ Int covers more ground than might at first appear to be the case. Having rationalised our choice of example function type, let us now get some actual work done. As a final preparation, we need to mention three laws that Monad instances κ are often expected to satisfy:5
We are now ready to derive free theorems involving (polymorphism over) type constructor classes. For example, functions f :: Monad µ ⇒ [µ Int] → µ Int as considered in the introduction will necessarily always satisfy (f, f ) ∈ ∀F . Monad F ⇒ [F id Int ] → F id Int , i.e., for every choice of type constructors κ1 and κ2 that are instances of Monad, and every Monad-action F : κ1 ⇔ κ2 , we have (fκ1 , fκ2 ) ∈ [F id Int ] → F id Int . In the next section we prove several theorems by instantiating the F here, and provide plenty of examples of interesting results obtained for concrete monads. An important role will be played by a notion connecting different Monad instances on a functional, rather than relational, level. Definition 2. Let κ1 and κ2 be instances of Monad and let h :: κ1 α → κ2 α. If • h ◦ return κ1 = return κ2 and • for every choice of closed types τ and τ 0 , m :: κ1 τ , and
k :: τ → κ1 τ 0 ,
return κ a >>=κ k = k a (1) m >>=κ return κ = m (2) (m >>=κ k) >>=κ q = m >>=κ (λa → k a >>=κ q) (3)
h (m >>=κ1 k) = h m >>=κ2 h ◦ k , then h is called a Monad-morphism.
Since Haskell does not enforce these laws, and it is easy to define Monad instances violating them, we will explicitly keep track of where the laws are required in our statements and proofs.
The two notions of Monad-action and Monad-morphism are strongly related, in that Monad-actions are closed under pointwise composition with Monad-morphisms or the inverses thereof, depending on whether the composition is from the left or from the right (Filinski and Støvring 2007, Proposition 3.7(2)).
4.
4.1
Purity Preservation
As mentioned in the introduction, one first intuitive statement we naturally expect to hold of any f :: Monad µ ⇒ [µ Int] → µ Int is that when all the monadic values supplied to f in the input list are actually pure (not associated with any proper monadic effect), then f ’s result value, though of some monadic type, should also be pure. After all, f itself, being polymorphic over µ, cannot introduce effects from any specific monad. This statement is expected to hold no matter what monad the input values live in. For example, if the input list consists of computations in the list monad, defined in the previous section and modelling nondeterminism, but all the concretely passed values actually correspond to deterministic computations, then we expect that f ’s result value also corresponds to a deterministic computation. Similarly, if the input list consists of IO computations, but we only pass ones that happen to have no side-effect at all, then f ’s result, though living in the IO monad, should also be side-effect-free. To capture the notion of “purity” independently of any concrete monad, we use the convention that the pure computations in any monad are those that may be the result of a call to return. Note that this does not mean that the values in the input list must syntactically be return-calls. Rather, each of them only needs to be semantically equivalent to some such call. The desired statement is now formalised as follows. It is proved in Appendix A, and is a corollary of Theorem 3 (to be given later).
One Application Field: Reasoning about Monadic Programs
For most of this section, we focus on functions f :: Monad µ ⇒ [µ Int] → µ Int. However, it should be emphasised that results of the same spirit can be systematically obtained for other function types involving quantification over Monad-restricted type constructor variables just as well. And note that the presence of the concrete type Int in the function signature makes any results we obtain for such f more, rather than less, interesting. For clearly there are strictly, and considerably, fewer functions of type Monad µ ⇒ [µ α] → µ α than there are of type Monad µ ⇒ [µ Int] → µ Int 4 , so proving a statement for all functions of the latter type demonstrates much more power than proving the same statement for all functions of the former type only. In other words, telling f what type of values are encapsulated in its monadic inputs and output entails more possible behaviours of f that our reasoning principle has to keep under control. Also, it is not the case that using Int in most examples in this section means that we might as well have monomorphised the monad interface as follows: class IntMonad µ where return :: Int → µ (>>=) :: µ → (Int → µ) → µ
Theorem 1. Let f :: Monad µ ⇒ [µ Int] → µ Int, let κ be an instance of Monad satisfying law (1), and let l :: [κ Int]. If every element in l is a return κ -image, then so is fκ l.
and thus are actually just proving results about a less interesting type IntMonad µ ⇒ [µ] → µ without any higher-orderedness (viz., quantifying only over a type variable rather than over a type constructor variable). This impression would be a misconception, as we do indeed prove results for functions critically depending on the use of higher-order polymorphism. That the type under consideration is Monad µ ⇒ [µ Int] → µ Int does by no way mean that monadic encapsulation is restricted to only integer values
We can now reason for specific monads as follows. Example 1. Let l :: [[Int]], i.e., l :: [κ Int] for κ = []. We might be interested in establishing that when every element
4 After all, any function (definition) of the type polymorphic over α can also
be given the more specific type, whereas of the functions f1 to f4 given in the introduction as examples for functions of the latter type only f1 can be given the former type as well.
5 Indeed, only a Monad instance satisfying these laws constitutes a “monad” in the mathematical sense of the word.
176
in l is (evaluated to) a singleton list, then the result of applying any f :: Monad µ ⇒ [µ Int] → µ Int to l will be a singleton list as well. While this propagation is easy to see for f1 , f2 , and f3 from the introduction, it is maybe not so immediately obvious for the f4 given there. However, Theorem 1 tells us without any further effort that the statement in question does indeed hold for f4 , and for any other f of the same type.
Assume we are interested in applying an f :: Monad µ ⇒ [µ Int] → µ Int to an l :: [Writer Int], yielding a monadic result of type Writer Int. Assume further that for some particular purpose during reasoning about the overall program, we are only interested in the actual integer value encapsulated in that result, as extracted by the following function:
Likewise, we obtain the statement about side-effect-free computations in the IO monad envisaged above. All we rely on then is that the IO monad, like the list monad, satisfies monad law (1).
Intuition suggests that then the value of p (f l) should not depend on any logging activity of elements in l. That is, if l were replaced by another l0 :: [Writer Int] encapsulating the same integer values, but potentially attached with different logging information, then p (f l0 ) should give exactly the same value. Since the given p fulfils the required conditions, Theorem 2 confirms this intuition.
4.2
p :: Writer α → α p (Writer (a, s)) = a
Safe Value Extraction
A second general statement we are interested in is to deal with the case that the monadic computations provided as input are not necessarily pure, but we have a way of discarding the monadic layer and recovering underlying values. Somewhat in the spirit of unsafePerformIO :: IO α → α, but for other monads and hopefully safe. Then, if we are interested only in a thus projected result value of f , can we show that it only depends on likewise projected input values, i.e., that we can discard any effects from the monadic computations in f ’s input list when we are not interested in the effectful part of the output computation? Clearly, it would be too much to expect this reduction to work for arbitrary “projections”, or even arbitrary monads. Rather, we need to devise appropriate restrictions and prove that they suffice. The formal statement is as follows.
It should also be instructive here to consider a negative example. Example 3. Recall the list monad defined in Section 3. It is tempting to use head :: [α] → α as an extraction function and expect that for every f :: Monad µ ⇒ [µ Int] → µ Int, we can factor head ◦ f as g ◦ (map head ) for some suitable g :: [Int] → Int. But actually this factorisation fails in a subtle way. Consider, for example, the (for the sake of simplicity, artificial) function f5 :: Monad µ ⇒ [µ Int] → µ Int f5 [ ] = return 0 f5 (m : ms) = do i ← m f5 (if i > 0 then ms else tail ms)
Theorem 2. Let f :: Monad µ ⇒ [µ Int] → µ Int, let κ be an instance of Monad, and let p :: κ α → α. If
Then for l = [[1], [ ]] and l0 = [[1, 0], [ ]], both of type [[Int]], we have map head l = map head l0 , but head (f5 l) 6= head (f5 l0 ). In fact, the left-hand side of this inequation leads to an “head of empty list”-error, whereas the righthand side delivers the value 0. Clearly, this means that the supposed g cannot exist for f5 and head . An explanation for the observed failure is provided by the conditions imposed on p in Theorem 2. It is simply not true that for every m and k, head (m >>= k) = head (k (head m)). More concretely, the failure for f5 observed above arises from this equation being violated for m = [1, 0] and k = λi → if i > 0 then [ ] else [0].
• p ◦ return κ = id and • for every choice of closed types τ and τ 0 , m :: κ τ , and
k :: τ → κ τ 0 ,
p (m >>=κ k) = p (k (p m)) , then p ◦ fκ gives the same result for any two lists of same length whose corresponding elements have the same pimages, i.e., p ◦ fκ can be “factored” as g ◦ (map p) for some suitable g :: [Int] → Int.6
The theorem is proved in Appendix B. Also, it is a corollary of Theorem 4. Note that no monad laws at all are needed in Theorem 2 and its proof. The same will be true for the other theorems we are going to provide, except for Theorem 5. But first, we consider several example applications of Theorem 2.
Since the previous (counter-)example is a bit peculiar in its reliance on runtime errors, let us consider a related setting without empty lists, an example also serving to further emphasise the predictive power of the conditions on p in Theorem 2.
Example 2. Consider the well-known writer, or logging, monad (specialised here to the String monoid):
Example 4. Assume, just for the scope of this example, that the type constructor [] yields (the types of) nonempty lists only. Clearly, it becomes an instance of Monad by just the same definition as given in Section 3. There are now several choices for a never failing extraction function p :: [α] → α. For example, p could be head , could be last, or could be the function that always returns the element in the middle position of its input list (and, say, the left one of the two middle elements in the case of a list of even length). But which of these candidates are “good” in the sense of providing, for
newtype Writer α = Writer (α, String) instance Monad Writer where return a = Writer (a, “”) Writer (a, s) >>= k = Writer (case k a of Writer (a0 , s0 ) → (a0 , s + + s0 )) 6 In fact, this g
is explicitly given as follows: unId ◦ fId ◦ (map Id), using the type constructor Id and its Monad instance definition from Appendix A.
177
• (return κ2 , return κ1 ) ∈ ∀R. R → F R, since for ev-
ery R and (a, b) ∈ R, (return κ2 a, h (return κ1 b)) = (return κ2 a, return κ2 b) ∈ κ2 R by (return κ2 , return κ2 ) ∈ ∀R. R → κ2 R (which holds due to return κ2 :: ∀α. α → κ2 α), and • ((>>=κ2 ), (>>=κ1 )) ∈ ∀R. ∀S. F R → ((R → F S) → F S), since for every R, S, (m1 , m2 ) ∈ (κ2 R) ; h−1 , and (k1 , k2 ) ∈ R → ((κ2 S) ; h−1 ),
every f :: Monad µ ⇒ [µ Int] → µ Int, a factorisation of p ◦ f into g ◦ (map p) ? The answer is provided by the two conditions on p in Theorem 2, which specialised to the (nonempty) list monad require that • for every a, p [a] = a, and • for every choice of closed types τ and τ 0 , m :: [τ ], and
k :: τ → [τ 0 ], p (concat (map k m)) = p (k (p m)).
(m1 >>=κ2 k1 , h (m2 >>=κ1 k2 )) = (m1 >>=κ2 k1 , h m2 >>=κ2 h ◦ k2 ) ∈ κ2 S
From these conditions it is easy to see that now p = head is good (in contrast to the situation in Example 3), and so is p = last, while the proposed “middle extractor” is not. It does not fulfil the second condition above, roughly because k does not necessarily map all its inputs to equally long lists. (A concrete counterexample f6 , of appropriate type, can easily be produced from this observation.)
4.3
by ((>>=κ2 ), (>>=κ2 )) ∈ ∀R. ∀S. κ2 R → ((R → κ2 S) → κ2 S), (m1 , h m2 ) ∈ κ2 R, and (k1 , h ◦ k2 ) ∈ R → κ2 S. Hence, (fκ2 , fκ1 ) ∈ [F id Int ] → F id Int . Given that we have F id Int = (κ2 id Int ) ; h−1 = h−1 , this implies the claim. (Note that κ2 id Int is the relational interpretation of the closed type κ2 Int, and thus itself denotes id κ2 Int .) Using Theorem 3, we can indeed prove the statements mentioned for the list and exception monads above. Here, for diversion, we instead prove some results about more stateful computations.
Monad Subspacing
Next, we would like to tackle reasoning not about the complete absence of (`a la Theorem 1), or disregard for (`a la Theorem 2), monadic effects, but about finer nuances. Often, we know certain computations to realise only some of the potential effects to which they would be entitled according to the monad they live in. If, for example, the effect under consideration is nondeterminism a` la the standard list monad, then we might know of some computations in that monad that they realise only none-or-onenondeterminism, i.e., never produce more than one answer, but may produce none at all. Or we might know that they realise only nonfailing-nondeterminism, i.e., always produce at least one answer, but may produce more than one. Then, we might want to argue that the respective nature of nondeterminism is preserved when combining such computations using, say, a function f :: Monad µ ⇒ [µ Int] → µ Int. This preservation would mean that applying any such f to any list of empty-or-singleton lists always gives an emptyor-singleton list as result, and that applying any such f to any list of nonempty lists only gives a nonempty list as result for sure. Or, in the case of an exception monad (Either String), we might want to establish that an application of f cannot possibly lead to any exceptional value (error description string) other than those already present somewhere in its input list. Such “invariants” can often be captured by identifying a certain “subspace” of the monadic type in question that forms itself a monad, or, indeed, by “embedding” another, “smaller”, monad into the one of interest. Formal counterparts of the intuition behind the previous sentence and the vague phrases occurring therein can be found in Definition 2 and the following theorem, as well as in the subsequent examples.
Example 5. Consider the well-known reader monad: newtype Reader ρ α = Reader (ρ → α) instance Monad (Reader ρ) where return a = Reader (λr → a) Reader g >>= k = Reader (λr → case k (g r) of Reader g 0 → g 0 r) Assume we are given a list of computations in a Reader monad, but it happens that all present computations depend only on a certain part of the environment type. For example, for some closed types τ1 and τ2 , l :: [Reader (τ1 , τ2 ) Int], and for every element Reader g in l, g (x, y) never depends on y. We come to expect that the same kind of independence should then hold for the result of applying any f :: Monad µ ⇒ [µ Int] → µ Int to l. And indeed it does hold by Theorem 3 with the following Monad-morphism: h :: Reader τ1 α → Reader (τ1 , τ2 ) α h (Reader g) = Reader (g ◦ fst)
It is also possible to connect more different monads, even involving the IO monad. Example 6. Let l :: [IO Int] and assume that the only sideeffects that elements in l have consist of writing strings to the output. We would like to use Theorem 3 to argue that the same is then true for the result of applying any f :: Monad µ ⇒ [µ Int] → µ Int to l. To this end, we need to somehow capture the concept of “writing (potentially empty) strings to the output as only side-effect of an IO computation” via an embedding from another monad. Quite naturally, we reuse the Writer monad from Example 2. The embedding function is as follows: h :: Writer α → IO α h (Writer (a, s)) = putStr s >> return a
Theorem 3. Let f :: Monad µ ⇒ [µ Int] → µ Int, let h :: κ1 α → κ2 α be a Monad-morphism, and let l :: [κ2 Int]. If every element in l is an h-image, then so is fκ2 l. Proof. We prove that for every l0 :: [κ1 Int], fκ2 (map h l0 ) = h (fκ1 l0 ) .
(4)
To do so, we first show that F : κ2 ⇔ κ1 with F R = (κ2 R) ; h−1 ,
What is left to do is to show that h is a Monad-morphism. But this property follows from putStr “” = return (),
where “;” is (forward) relation composition and “−1 ” gives the inverse of a function graph, is a Monad-action. Indeed,
178
putStr (s + + s0 ) = putStr s >> putStr s0 , and monad laws (1) and (3) for the IO monad.
Example 8. Consider the well-known exception monad: instance Monad (Either String) where return a = Right a Left err >>= k = Left err Right a >>= k = k a
Similarly to the above, it would also be possible to show that when the IO computations in l do only read from the input (via, possibly repeated, calls to getChar ), then the same is true of f l. Instead of exercising this through, we turn to general state transformers.
We would like to argue that if we are only interested in whether the result of f for some input list over the type Either String Int is an exceptional value or not (and which ordinary value is encapsulated in the latter case), but do not care what the concrete error description string is in the former case, then the answer is independent of the concrete error description strings potentially appearing in the input list. Formally, let l1 , l2 :: [Either String Int] be of same length, and let corresponding elements either be both tagged with Left (but not necessarily containing the same strings) or be identical Right-tagged values. Then for every f :: Monad µ ⇒ [µ Int] → µ Int, f l1 and f l2 either are both tagged with Left or are identical Right-tagged values. This statement holds by Theorem 4 with the following Monad-morphism:
Example 7. Consider the well-known state monad: newtype State σ α = State (σ → (α, σ)) instance Monad (State σ) where return a = State (λs → (a, s)) State g >>= k = State (λs → let (a, s0 ) = g s in case k a of State g 0 → g 0 s0 ) Intuitively, this monad extends the reader monad by not only allowing a computation to depend on an input state, but also to transform the state to be passed to a subsequent computation. A natural question now is whether being a specific state transformer that actually corresponds to a read-only computation is an invariant that is preserved when computations are combined. That is, given some closed type τ and l :: [State τ Int] such that for every element State g in l, snd ◦ g = id , is it the case that for every f :: Monad µ ⇒ [µ Int] → µ Int, also f l is of the form State g for some g with snd ◦ g = id ? The positive answer is provided by Theorem 3 with the following Monad-morphism:
h :: Either String α → Maybe α h (Left err ) = Nothing h (Right a) = Just a
4.5
h :: Reader τ α → State τ α h (Reader g) = State (λs → (g s, s))
fmap :: Monad µ ⇒ (α → β) → µ α → µ β fmap g m = m >>= return ◦ g
Similarly to the above, we can show preservation of the invariant that a computation transforms the state “in the background”, while the primary result value is independent of the input state. That is, if for every element State g in l, there exists an i :: Int with fst ◦ g = const i, then the same applies to f l. It should also be possible to transfer the above kind of reasoning to the ST monad (Launchbury and Peyton Jones 1995). 4.4
A More Polymorphic Example
Just to reinforce that our approach is not specific to our pet type alone, we end this section by giving a theorem obtained for another type, the one of sequence from the introduction, also showing that mixed quantification over both type constructor variables and ordinary type variables can very well be handled. The theorem’s statement involves the following function:
Theorem 5. Let f :: Monad µ ⇒ [µ α] → µ [α] and let h :: κ1 α → κ2 α be a Monad-morphism. If κ2 satisfies law (2), then for every choice of closed types τ1 and τ2 and g :: τ1 → τ2 ,
Effect Abstraction
fκ2 ◦ map (fmap κ2 g) ◦ map h = fmap κ2 (map g) ◦ h ◦ fκ1 .7
As a final statement about our pet type, Monad µ ⇒ [µ Int] → µ Int, we would like to show that we can abstract from some aspects of the effectful computations in the input list if we are interested in the effects of the final result only up to the same abstraction. For conveying between the full effect space and its abstraction, we again use Monad-morphisms.
Intuitively, this theorem means that any f of type Monad µ ⇒ [µ α] → µ [α] commutes with, both, transformations on the monad structure and transformations on the element level. The occurrences of map and fmap are solely there to bring those transformations h and g into the proper positions with respect to the different nestings of the type constructors µ and [] on the input and output sides of f . Note that by setting either g or h to id , we obtain the specialised versions
Theorem 4. Let f :: Monad µ ⇒ [µ Int] → µ Int and let h :: κ1 α → κ2 α be a Monad-morphism. Then h ◦ fκ1 gives the same result for any two lists of same length whose corresponding elements have the same h-images.
fκ2 ◦ map h = h ◦ fκ1 Proof. Let l1 , l2 :: [κ1 Int] be such that map h l1 = map h l2 . Then h (fκ1 l1 ) = h (fκ1 l2 ) by statement (4) from the proof of Theorem 3.
7 For the curious reader: the proof derives this statement from (f , f ) ∈ κ2 κ1 [F g −1 ] → F [g −1 ] for the same Monad-action F : κ2 ⇔ κ1 as used in the proof of Theorem 3.
179
by emptyR, consR, and appendR (and/or applying rep to explicit lists). But this implicit assumption is not immediately in reach for formal grasp. So it would be nice to be able to provide a single, conclusive correctness statement for transformations like the one above. One way to do so was presented by Voigtl¨ander (2002), but it requires a certain restructuring of code that can hamper compositionality and flexibility by introducing abstraction at fixed program points (via lambda-abstraction and so-called vanish-combinators). This also brings us to the second problem with the simple approach above. When, and how, should we switch between the original and the alternative representations of lists during program construction? If we first write the original version of flatten and only later, after observing a quadratic runtime overhead, switch manually to the flatten 0 -version, then this rewriting is quite cumbersome, in particular when it has to be done repeatedly for different functions. Of course, we could decide to always use emptyR, consR, and appendR from the beginning, to be on the safe side. But actually this strategy is not so safe, efficiency-wise, because the representation of lists by functions carries its own (constant-factor) overhead. If a function does not use appends in a harmful way, then we do not want to pay this price. Hence, using the alternative presentation in a particular situation should be a conscious decision, not a default. And assume that later on we change the behaviour of flatten, say, to explore only a single path through the input tree, so that no appends at all arise. Certainly, we do not want to have to go and manually switch back to the, now sufficient, original list representation. The cure to our woes here is almost obvious, and has often been applied in similar situations: simply use overloading. Specifically, we can declare a type constructor class as follows:
and fκ ◦ map (fmap κ g) = fmap κ (map g) ◦ fκ . (5) Further specialising the latter by choosing the identity monad for κ, we would also essentially recover the free theorem derived for f :: [α] → [α] in Section 2.
5.
Another Application: Difference Lists, Transparently
It is a well-known problem that computations over lists sometimes suffer from a quadratic runtime blow-up due to left-associatively nested appends. For example, this is so for flattening a tree of type data Tree α = Leaf α | Node (Tree α) (Tree α) using the following function: flatten :: Tree α → [α] flatten (Leaf a) = [a] flatten (Node t1 t2 ) = flatten t1 + + flatten t2 An equally well-known solution is to switch to an alternative representation of lists as functions, by abstraction over the list end, often called difference lists. In the formulation of Hughes (1986), but encapsulated as an explicitly new data type: newtype DList α = DL {unDL :: [α] → [α]} rep :: [α] → DList α rep l = DL (l + +) abs :: DList α → [α] abs (DL f ) = f [ ]
class ListLike δ where empty :: δ α cons :: α → δ α → δ α append :: δ α → δ α → δ α
emptyR :: DList α emptyR = DL id consR :: α → DList α → DList α consR a (DL f ) = DL ((a :) ◦ f )
and code flatten in the following form: flatten :: Tree α → (∀δ. ListLike δ ⇒ δ α) flatten (Leaf a) = cons a empty flatten (Node t1 t2 ) = append (flatten t1 ) (flatten t2 )
appendR :: DList α → DList α → DList α appendR (DL f ) (DL g) = DL (f ◦ g) Then, flattening a tree into a list in the new representation can be done using the following function:
Then, with the obvious instance definitions instance ListLike [] where empty = [ ] cons = (:) append = (+ +)
flatten 0 :: Tree α → DList α flatten 0 (Leaf a) = consR a emptyR flatten 0 (Node t1 t2 ) = appendR (flatten 0 t1 ) (flatten 0 t2 ) and
and a more efficient variant of the original function, with its original type, can be recovered as follows:
instance ListLike DList where empty = emptyR cons = consR append = appendR we can use the single version of flatten above both to produce ordinary lists and to produce difference lists. The choice between the two will be made automatically by the type checker, depending on the context in which a call to flatten occurs. For example, in
flatten :: Tree α → [α] flatten = abs ◦ flatten 0 There are two problems with this approach. One is correctness. How do we know that the new flatten is equivalent to the original one? We could try to argue by “distributing” abs over the definition of flatten 0 , using abs emptyR = [ ], abs (consR a as) = a : abs as, and abs (appendR as bs) = abs as + + abs bs .
last (flatten t)
(6)
(7)
the ordinary list representation will be used, due to the input type of last. Actually, (7) will compile (under GHC, at least) to exactly the same code as last (flatten t) for the original definition of flatten from the very beginning of this section. Any overhead related to the type class abstraction is simply eliminated by a standard optimisation. In particular, this means that where the original representation of lists would have perfectly sufficed, programming against the abstract interface provided by the ListLike class does
But actually the last equation does not hold in general. The reason is that there are as :: DList τ that are not in the image of rep. Consider, for example, as = DL reverse. Then neither is as = rep l for any l, nor does (6) hold for every bs. Any argument “by distributing abs” would thus have to rely on the implicit assumption that a certain discipline has been exercised when going from the original flatten to flatten 0 by replacing [ ], (:), and (+ +)
180
by (a, b) ∈ R and (f l1 , bs + + l2 ) ∈ [R] (which holds due to (f, (bs + +)) ∈ [R] → [R] and (l1 , l2 ) ∈ [R]), and • (appendR, (+ +)) ∈ ∀R. F R → (F R → F R), since for every R, (f, as) ∈ ([R] → [R]) ; (+ +)−1 , (g, bs) ∈ ([R] → −1 [R]) ; (+ +) , and (l1 , l2 ) ∈ [R],
no harm either. On the other hand, (7) of course still suffers from the same quadratic runtime blow-up as with the original definition of flatten. But now we can switch to the better behaved difference list representation without touching the code of flatten at all, by simply using last (abs (flatten t)) . (8) Here the (input) type of abs determines flatten to use emptyR, consR, and appendR, leading to linear runtime. Can we now also answer the correctness question more satisfactorily? Given the forms of (7) and (8), it is tempting to simply conjecture that abs t = t for any t. But this conjecture cannot be quite right, as abs has different input and output types. Also, we have already observed that some t of abs’s input type are problematic by not corresponding to any actual list. The coup now is to only consider t that only use the ListLike interface, rather than any specific operations related to DList as such. That is, we will indeed prove that for every closed type τ and t :: ListLike δ ⇒ δ τ ,
(unDL (appendR (DL f ) (DL g)) l1 , (as + + bs) + + l2 ) = (f (g l1 ), as + + (bs + + l2 )) ∈ [R] by (f, (as + +)) ∈ [R] → [R], (g, (bs + +)) ∈ [R] → [R], and (l1 , l2 ) ∈ [R]. Hence, (tDList , t[] ) ∈ F id τ . Given that we have F id τ = unDL ; ([id τ ] → [id τ ]) ; (+ +)−1 = unDL ; (+ +)−1 , this implies (9). Note that the ListLike-action F : DList ⇔ [] used in the above proof is the same as F R = (DList R) ; rep −1 ,
abs tDList = t[] .
given that DList R = unDL ; ([R] → [R]) ; DL. This connection suggests the following more general theorem, which can actually be proved much like above.
Since the polymorphism over δ in the type of t is so important, we follow Voigtl¨ander (2008a) and make it an explicit requirement in a function that we will use instead of abs for switching from the original to the alternative representation of lists:
Theorem 7. Let t :: ListLike δ ⇒ δ τ for some closed type τ , let κ1 and κ2 be instances of ListLike, and let h :: κ1 α → κ2 α. If
improve :: (∀δ. ListLike δ ⇒ δ α) → [α] improve t = abs t Now, when we observe the problematic runtime overhead in (7), we can replace it by
• h empty κ = empty κ , 1 2 • for every closed type τ , a :: τ , and as
:: κ1 τ , h (cons κ1 a as) = cons κ2 a (h as), and • for every closed type τ and as, bs :: κ1 τ , h (append κ1 as bs) = append κ2 (h as) (h bs),
last (improve (flatten t)) . That this replacement does not change the semantics of the program is established by the following theorem, which provides the soughtafter general correctness statement.
then h tκ1 = tκ2 .
Theorem 6. Let t :: ListLike δ ⇒ δ τ for some closed type τ . Then improve t = t[] .
Theorem 6 is a special case of this theorem by setting κ1 = [], κ2 = DList, and h = rep, and observing that • rep [ ] = emptyR,
Proof. We prove
• for every closed type τ , a :: τ , and as :: [τ ], rep (a : as) =
unDL tDList = (t[] + +) ,
consR a (rep as),
(9)
• for every closed type τ and as, bs :: [τ ], rep (as + + bs) =
which by the definitions of improve and abs, and by t[] + + [] = t[] , implies the claim. To do so, we first show that F : DList ⇔ [] with F R = unDL ; ([R] → [R]) ; (+ +)−1 is a ListLike-action, where the latter concept is defined as any relational action F : κ1 ⇔ κ2 for type constructors κ1 and κ2 that are instances of ListLike such that
appendR (rep as) (rep bs), and • abs ◦ rep = id ,
all of which hold by easy calculations. One key observation here is that the third of the above observations does actually hold, in contrast to its faulty “dual” (6) considered earlier in this section. Of course, free theorems can now also be derived for other types than those considered in Theorems 6 and 7. For example, for every closed type τ , f :: ListLike δ ⇒ δ τ → δ τ , and h as in Theorem 7, we get that:
• (empty κ , empty κ ) ∈ ∀R. F R, 1 2 • (cons κ1 , cons κ2 ) ∈ ∀R. R → (F R → F R), and • (append κ , append κ ) ∈ ∀R. F R → (F R → F R). 1
fκ2 ◦ h = h ◦ fκ1 .
2
Indeed,
6.
• (emptyR, [ ]) ∈ ∀R. F R, since for every R and (l1 , l2 ) ∈
[R], (unDL emptyR l1 , [ ] + + l2 ) = (l1 , l2 ) ∈ [R], • (consR, (:)) ∈ ∀R. R → (F R → F R), since for every R, (a, b) ∈ R, (f, bs) ∈ ([R] → [R]) ; (+ +)−1 , and (l1 , l2 ) ∈ [R],
Discussion and Related Work
Of course, statements like that of Theorem 7 are not an entirely new revelation. That statement can be read as a typical fusion law for compatible morphisms between algebras over the signature described by the ListLike class declaration. (For a given τ , consider ListLike δ ⇒ δ τ as the corresponding initial algebra, κ1 τ and κ2 τ as two further algebras, and the operation ·κi of instantiating
(unDL (consR a (DL f )) l1 , (b : bs) + + l2 ) = (a : f l1 , b : bs + + l2 ) ∈ [R]
181
a t :: ListLike δ ⇒ δ τ to a tκi :: κi τ as initial algebra morphism, or catamorphism. Then the conditions on h in Theorem 7 make it an algebra morphism and the theorem’s conclusion, also expressible as h ◦ ·κ1 = ·κ2 , is “just” that of the standard catamorphism fusion law.) But being able to derive such statements directly from the types in the language, based on its built-in abstraction facilities, immediately as well for more complicated types (like ListLike δ ⇒ δ τ → δ τ instead of ListLike δ ⇒ δ τ ), and all this without going through category-theoretic hoops, is new and unique to our approach. There has been quite some interest recently in enhancing the state of the art in reasoning about monadic programs. Filinski and Støvring (2007) study induction principles for effectful data types. These principles are used for reasoning about functions on data types involving specific monadic effects (rather than about functions that are parametric over some monad), and based on the functions’ defining equations (rather than based on their types only), and thus are orthogonal to our free theorems. But for their example applications to formal models of backtracking, Filinski and Støvring also use a form of relational reasoning very close to the one appearing in our invocation of relational parametricity. In particular, our Definition 1 corresponds to their Definition 3.3. They also use monad morphisms (not to be confused with their monadalgebra morphisms, or rigid functions, playing the key role in their induction principles). The scope of their relational reasoning is different, though. They use it for establishing the observational equivalence of different implementations of the same monadic effect. This is, of course, one of the classical uses of relational parametricity: representation independence in different realizations of an abstract data type. But it is only one possible use, and our treatment of full polymorphism opens the door to other uses also in connection with monadic programs. Rather than only relating different, but semantically equivalent, implementations of the same monadic effect (as hard-wired into Filinski and Støvring’s Definition 3.5), we actually connect monads embodying different effects. These connections lead to applications not previously in reach, such as our reasoning about preservation of invariants. It is worth pointing out that Filinski (2007) does use monad morphisms for “subeffecting”, but only for the discussion of hierarchies inside each one of two competing implementations of the same set of monadic effects; the relational reasoning (via Monad-actions and so forth) is then orthogonal to these hierarchies and again can only lead to statements about observational equivalence of the two realizations overall, rather than to more nuanced statements about programs in one of them as such. The reason again, as with Filinski and Støvring (2007), is that no full polymorphism is considered, but only parametrisation over same-effect-monads on top-level. Interestingly, though, the key step in all our proofs in Section 4, namely finding a suitable Monad-action, can be streamlined in the spirit of Proposition 3.7 of Filinski and Støvring (2007) or Lemmas 45, 46 of Filinski (2007). It seems fair to mention that the formal accounts of Filinski and Støvring are very complex, but that this is necessarily so because they deal with general recursion at both term and type level, while we have completely dodged such issues. Treating general recursion in a semantic framework typically involves a good deal of domain theory such as considered by Birkedal et al. (2007). We only provide a very brief sketch of what interactions we expect between general recursion and our developments from the previous sections in Appendix C. Swierstra (2008) proposes to code against modularly assembled free monads, where the assembling takes place by building coproducts of signature functors corresponding to the term languages of free monads. The associated type signatures are able to convey some of the information captured by our approach. For example, a monadic type Term PutStr Int can be used to describe com-
putations whose only possible side-effect is that of writing strings to the output. Passing a list of values of that type to a function f :: Monad µ ⇒ [µ Int] → µ Int clearly results in a value of type Term PutStr Int as well. Thus, if it is guaranteed (note the proof obligation) that “execution” of such a term value, on a kind of virtual machine (Swierstra and Altenkirch 2007) or in the actual IO monad, does indeed have no other side effect than potential output, then one gets a statement in the spirit of our Example 6. On the other hand, statements like the one in our Example 8 (also, say, reformulated for exceptions in the IO monad) are not in reach with that approach alone. Moreover, Swierstra’s approach to “subeffecting” depends very much on syntax, essentially on term language inclusion along with proof obligations on the execution functions from terms to some semantic space. This dependence prevents directly obtaining statements roughly analogous to our Examples 5 and 7 using his approach. Also, depending on syntactic inclusion is a very strong restriction indeed. For example, putStr “” is semantically equivalent to return (), and thus without visible side-effect. But nevertheless, any computation syntactically containing a call to putStr would of necessity be assigned a type in a monad Term g with g “containing” (with respect to Swierstra’s functor-level relation :≺:) the functor PutStr, even when that call’s argument would eventually evaluate to the empty string. Thus, such a computation would be banned from the input list in a statement like the one we give below Example 6. It is not so with our more semantical approach. Dealing more specifically with concrete monads is the topic of recent works by Hutton and Fulger (2008), using point-free equational reasoning, and by Nanevski et al. (2008), employing an axiomatic extension of dependent type theory. On the tool side, we already mentioned the free theorems generator at http://linux.tcs.inf.tu-dresden.de/~voigt/ft/. It deals gracefully with ordinary type classes (in the offline, shellbased version even with user-defined ones), but has not yet been extended for type constructor classes. There is also another free theorems generator, written by Andrew Bromage, running in Lambdabot (http://haskell.org/haskellwiki/Lambdabot). It does not know about type or type constructor classes, but deals with type constructors by treating them as fixed functors. Thus, it can, for example, derive the statement (5) for functions fκ :: [κ α] → κ [α], but not more general and more interesting statements like those given in Theorem 5 and earlier, connecting different Monad instances, concerning the beyond-functor aspects of monads, or our results about ListLike.
Acknowledgments I would like to thank the anonymous reviewers of more than one version of this paper who have helped to improve it through their criticism and suggestions. Also, I would like to thank Helmut Seidl, who inspired me to consider free theorems involving type constructor classes in the first place by asking a challenging question regarding the power of type(-only)-based reasoning about monadic programs during a train trip through Munich quite some time ago. (The answer to his question is essentially Example 7.)
References L. Birkedal, R.E. Møgelberg, and R.L. Petersen. Domain-theoretical models of parametric polymorphism. Theoretical Computer Science, 388 (1–3):152–172, 2007. S. B¨ohme. Free theorems for sublanguages of Haskell. Master’s thesis, Technische Universit¨at Dresden, 2007. N.A. Danielsson, R.J.M. Hughes, P. Jansson, and J. Gibbons. Fast and loose reasoning is morally correct. In Principles of Programming Languages, Proceedings, pages 206–217. ACM Press, 2006.
182
L. Fegaras and T. Sheard. Revisiting catamorphisms over datatypes with embedded functions (or, Programs from outer space). In Principles of Programming Languages, Proceedings, pages 284–294. ACM Press, 1996.
P. Wadler. Theorems for free! In Functional Programming Languages and Computer Architecture, Proceedings, pages 347–359. ACM Press, 1989. P. Wadler. The essence of functional programming (Invited talk). In Principles of Programming Languages, Proceedings, pages 1–14. ACM Press, 1992. P. Wadler and S. Blott. How to make ad-hoc polymorphism less ad hoc. In Principles of Programming Languages, Proceedings, pages 60–76. ACM Press, 1989.
A. Filinski. On the relations between monadic semantics. Theoretical Computer Science, 375(1–3):41–75, 2007. A. Filinski and K. Støvring. Inductive reasoning about effectful data types. In International Conference on Functional Programming, Proceedings, pages 97–110. ACM Press, 2007. A. Gill, J. Launchbury, and S.L. Peyton Jones. A short cut to deforestation. In Functional Programming Languages and Computer Architecture, Proceedings, pages 223–232. ACM Press, 1993.
A.
Proof of Theorem 1
We prove that for every l0 :: [Int],
R.J.M. Hughes. A novel representation of lists and its application to the function “reverse”. Information Processing Letters, 22(3):141–144, 1986.
fκ (map return κ l0 ) = return κ (unId (fId (map Id l0 ))) , where
G. Hutton and D. Fulger. Reasoning about effects: Seeing the wood through the trees. In Trends in Functional Programming, Draft Proceedings, 2008.
newtype Id α = Id {unId :: α} instance Monad Id where return a = Id a Id a >>= k = k a To do so, we first show that F : κ ⇔ Id with
P. Johann and J. Voigtl¨ander. Free theorems in the presence of seq. In Principles of Programming Languages, Proceedings, pages 99–110. ACM Press, 2004. J. Kuˇcan. Metatheorems about Convertibility in Typed Lambda Calculi: Applications to CPS Transform and “Free Theorems”. PhD thesis, Massachusetts Institute of Technology, 1997.
F R = return −1 κ ; R ; Id , where “;” is (forward) relation composition and “−1 ” gives the inverse of a function graph, is a Monad-action. Indeed,
J. Launchbury and S.L. Peyton Jones. State in Haskell. Lisp and Symbolic Computation, 8(4):293–341, 1995. S. Liang, P. Hudak, and M.P. Jones. Monad transformers and modular interpreters. In Principles of Programming Languages, Proceedings, pages 333–343. ACM Press, 1995.
• (return κ , return Id ) ∈ ∀R. R → F R, since for every R and
J.C. Mitchell and A.R. Meyer. Second-order logical relations (Extended abstract). In Logic of Programs, Proceedings, volume 193 of LNCS, pages 225–236. Springer-Verlag, 1985.
• ((>>=κ ), (>>=Id )) ∈ ∀R. ∀S. F R → ((R → F S) →
(a, b) ∈ R, (return κ a, return Id b) = (return κ a, Id b) ∈ return −1 κ ; R ; Id, and F S), since for every R, S, (a, b) ∈ R, and (k1 , k2 ) ∈ R → F S, (return κ a >>=κ k1 , Id b >>=Id k2 ) = (k1 a, k2 b) ∈ F S. (Note the use of monad law (1) for κ.)
E. Moggi. Notions of computation and monads. Information and Computation, 93(1):55–92, 1991. A. Nanevski, G. Morrisett, A. Shinnar, P. Govereau, and L. Birkedal. Ynot: Dependent types for imperative programs. In International Conference on Functional Programming, Proceedings, pages 229–240. ACM Press, 2008.
Hence, by what we derived towards the end of Section 3, (fκ , fId ) ∈ [F id Int ] → F id Int . Given that we have F id Int = return −1 κ ; Id = (return κ ◦ unId)−1 , this implies the claim.
S.L. Peyton Jones and P. Wadler. Imperative functional programming. In Principles of Programming Languages, Proceedings, pages 71–84. ACM Press, 1993.
B.
Proof of Theorem 2
We prove that for every l :: [κ Int],
J.C. Reynolds. Types, abstraction and parametric polymorphism. In Information Processing, Proceedings, pages 513–523. Elsevier, 1983.
p (fκ l) = unId (fId (map (Id ◦ p) l)) , where the type constructor Id and its Monad instance definition are as in the proof of Theorem 1. To do so, we first show that F : κ ⇔ Id with F R = p ; R ; Id is a Monad-action. Indeed,
F. Stenger and J. Voigtl¨ander. Parametricity for Haskell with imprecise error semantics. In Typed Lambda Calculi and Applications, Proceedings, volume 5608 of LNCS, pages 294–308. Springer-Verlag, 2009. W. Swierstra. Data types a` la carte. Journal of Functional Programming, 18(4):423–436, 2008. W. Swierstra and T. Altenkirch. Beauty in the beast — A functional semantics for the awkward squad. In Haskell Workshop, Proceedings, pages 25–36. ACM Press, 2007.
• (return κ , return Id ) ∈ ∀R. R → F R, since for every R and
I. Takeuti. The theory of parametricity in lambda cube. Manuscript, 2001.
• ((>>=κ ), (>>=Id )) ∈ ∀R. ∀S. F R → ((R → F S) →
J. Voigtl¨ander. Concatenate, reverse and map vanish for free. In International Conference on Functional Programming, Proceedings, pages 14–25. ACM Press, 2002.
F S), since for every R, S, (m, b) ∈ p ; R, and (k1 , k2 ) ∈ R → F S, (m >>=κ k1 , Id b >>=Id k2 ) ∈ p ; S ; Id by p (m >>=κ k1 ) = p (k1 (p m)) and (k1 (p m), k2 b) ∈ p ; S ; Id (which holds due to (k1 , k2 ) ∈ R → F S and (p m, b) ∈ R).
(a, b) ∈ R, (return κ a, b) ∈ p ; R by p (return κ a) = a, and
J. Voigtl¨ander. Asymptotic improvement of computations over free monads. In Mathematics of Program Construction, Proceedings, volume 5133 of LNCS, pages 388–403. Springer-Verlag, 2008a.
Hence, (fκ , fId ) ∈ [F id Int ] → F id Int . Given that we have F id Int = p ; Id = Id ◦ p = p ; unId−1 , this implies the claim.
J. Voigtl¨ander. Much ado about two: A pearl on parallel prefix computation. In Principles of Programming Languages, Proceedings, pages 29–35. ACM Press, 2008b.
C.
J. Voigtl¨ander. Bidirectionalization for free! In Principles of Programming Languages, Proceedings, pages 165–176. ACM Press, 2009.
Free Theorems, the Ugly Truth
Free theorems as described in Section 2 are beautiful. And very nice. Almost too good to be true. And actually they are not. At least not unrestricted and in a setting more closely resembling a modern
D. Vytiniotis and S. Weirich. Type-safe cast does no harm: Syntactic parametricity for Fω and beyond. Manuscript, 2009.
183
For the extension to the setting with type constructor classes (cf. Section 3), we will need to mandate that any relational action, · now denoted F : κ1 ⇔ κ2 , must preserve strictness, i.e., map · · R : τ1 ⇔ τ2 to F R : κ1 τ1 ⇔ κ2 τ2 . Apart from that, Definition 1, for example, is expected to remain unchanged (except that R and S will now range over strict relations, of course). Under these assumptions, we can investigate the impact of the presence of general recursion on the results seen in the main body of this paper. Consider Theorem 1, for example. In order to have · F : κ ⇔ Id in its proof, we need to change the definition of F R as follows:
functional language than the plain polymorphic lambda-calculus for which relational parametricity was originally conceived. In particular, problems are caused by general recursion with its potential for nontermination. We have purposefully ignored this issue throughout the main body of the paper, so as to be able to explain our ideas and new abstractions in the most basic surrounding. In a sense, our reasoning has been “up to ⊥”, or “fast and loose, but morally correct” (Danielsson et al. 2006). We leave a full formal treatment of free theorems involving type constructor classes in the presence of partiality as a challenge for future work, but use this appendix to outline some refinements that are expected to play a central role in such a formalisation. So what is the problem with potential nontermination? Let us first discuss this question based on the simple example
F R = {(⊥, ⊥)} ∪ (return −1 κ ; R ; Id) . For this relational action to be a Monad-action, we would need the additional condition that ⊥ >>=κ k1 = k1 ⊥ for any choice of k1 . Then, (fκ , fId ) ∈ [F id Int ] → F id Int would allow to derive the following variant, valid in the presence of general recursion and ⊥.
f :: [α] → [α] from Section 2. There, we argued that the output list of any such f can only ever contain elements from the input list. But this claim is not true anymore now, because f might just as well choose, for some element position of its output list, to start an arbitrary looping computation. That is, while f certainly (and still) cannot possibly make up new elements of any concrete type to put into the output, such as 42 or True, it may very well put ⊥ there, even while not knowing the element type of the lists it operates over, because ⊥ does exist at every type. So the erstwhile claim that for any input list l the output list f l consists solely of elements from l has to be refined as follows.
Theorem 1’. Let f :: Monad µ ⇒ [µ Int] → µ Int, let κ be an instance of Monad satisfying law (1) and ⊥ >>=κ k = k ⊥ for every (type-appropriate) k, and let l :: [κ Int]. If every element in l is a return κ -image or ⊥, then so is fκ l.
Note that the Reader monad, for example, satisfies the conditions for applying the thus adapted theorem. Similar repairs are conceivable for the other statements we have derived, or one might want to derive. Just as another sample, we expect Example 7 to change as follows.
For any input list l the (potentially partial or infinite) output list f l consists solely of elements from l and/or ⊥. The decisions about which elements from l to propagate to the output list, in which order and multiplicity, and where to put ⊥ can again only be made based on the input list l, and only by inspecting its length (or running into an undefined tail or an infinite list).
Example 7’. Let f :: Monad µ ⇒ [µ Int] → µ Int, let τ be a closed type, and let l :: [State τ Int]. If for every element State g in l, the property P (g) defined as
So for any pair of lists l and l0 of same length (refining this notion to take partial and infinite lists into account) the lists f l and f l0 are formed by making the same position-wise selections of elements from l and l0 , respectively, and by inserting ⊥ at the same positions, if any.
P (g) := ∀s. snd (g s) = s ∨ snd (g s) = ⊥ holds, then also f l is of the form State g for some g with P (g).
For any l0 = map g l, we then still have that f l and f l0 are of the same length and contain position-wise exactly corresponding elements from l and l0 = map g l, at those positions where f takes over elements from its input rather than inserting ⊥. For those positions where f does insert ⊥, which will then happen equally for f l and f l0 , we may only argue that the element in f l0 contains the g-image of the corresponding element in f l if indeed ⊥ is the g-image of ⊥, that is, if g is a strict function.
Note that even if we had kept the stronger precondition that snd ◦ g = id for every element State g in l, it would be impossible to prove snd ◦ g = id instead of the weaker P (g) for f l = State g. Just consider the case that f invokes an immediately looping computation, i.e., f l = ⊥ = State ⊥.8 The g = ⊥ here satisfies P (g), but not snd ◦ g = id .
So for any list l and, importantly, strict function g, we have f (map g l) = map g (f l). The formal counterpart to the extra care exercised above regarding potential occurrences of ⊥ is the provision of Wadler (1989, Section 7) that only strict and continuous relations should be allowed as interpretations for types. In particular, when interpreting quantification over type variables by quantification over relation variables, those quantified relations are required to contain the pair (⊥, ⊥), also signified via · the added · in the new notation R : τ1 ⇔ τ2 . With straightforward changes to the required constructions on relations, such · as explicitly including the pair (⊥, ⊥) in [R] : [τ1 ] ⇔ [τ2 ] and · Maybe R : Maybe τ1 ⇔ Maybe τ2 , and replacing the least by the greatest fixpoint in the definition of [R], we get a treatment of free theorems that is sound even for a language including general recursion, and thus nontermination.
8 The
equality ⊥ = State ⊥ holds by the semantics of newtype in Haskell.
184
Experience Report: Haskell in the “Real World” Writing a Commercial Application in a Lazy Functional Language Curt J. Sampson Starling Software, Tokyo, Japan
[email protected]
Abstract
as C# and Python). When we embarked on this project, we had no significant functional programming experience. In the mid-2000s a fellow programmer extolled to me the virtues of Scheme and, following up on this, I found that people such as Paul Graham were also making convincing arguments (in essays such as “Beating the Averages” 2 (3)) that functional programming would increase productivity and reduce errors. Working through The Little Schemer(1) I was impressed by the concision and adaptability of what was admittedly toy code. Upon founding a software development company soon after this, my co-founder and I agreed that pursuing functional programming was likely to be beneficial, and thus we should learn a functional language and develop a software project in it. There things sat for about two years, until an opportunity arose. In the spring of 2008 we were approached by a potential client to write an financial application. He had previous experience working on a similar application in Java, and approached us with the idea of doing something similar.
I describe the initial attempt of experienced business software developers with minimal functional programming background to write a non-trivial, business-critical application entirely in Haskell. Some parts of the application domain are well suited to a mathematically-oriented language; others are more typically done in languages such as C++. I discuss the advantages and difficulties of Haskell in these circumstances, with a particular focus on issues that commercial developers find important but that may receive less attention from the academic community. I conclude that, while academic implementations of “advanced” programming languages arguably may lag somewhat behind implementations of commercial languages in certain ways important to businesses, this appears relatively easy to fix, and that the other advantages that they offer make them a good, albeit long-term, investment for companies where effective IT implementation can offer a crucial advantage to success. Categories and Subject Descriptors D.1.1 [Programming Techniques]: Applicative (Functional) Programming; D.2.3 [General]: Coding Tools and Techniques; D.3.2 [Programming Languages]: Language Classifications—Applicative (functional) Languages General Terms Performance
1.2
Experimentation, Human Factors, Languages,
Keywords functional programming, Haskell, commercial programming, financial systems
1.
Introduction
1.1
Selling the Client
We felt that the client’s application domain could benefit from using a more powerful language, and we saw this as an opportunity to explore functional programming. Convincing the client to do this took some work, especially since we had no real experience with functional languages at that point. We took a two-pronged approach to selling functional programming for this job. First, we explained that we had significant programming experience in more traditional languages, especially Java. We pointed out that this made us familiar with the limitations of these languages: so familiar, in fact, that in the case of Java we had already moved away from the language to escape those very limitations. We explained that, for us, switching languages every few years was a normal technology upgrade (much as any other industry will incorporate new advancements in the state of the art) and we had previous experience with working through these sorts of changes. Second, we argued that we had been for some time looking to move on to a new language, had done significant research in that direction, and had already identified many of the specific things needed to make the change successful and profitable. As well as general promises of better productivity and fewer bugs, we pointed to specific features we wanted in a new language for which the client himself was looking. In particular, he had expressed a preference for a system where he would have the ability to examine and modify the financial algorithms himself; we explained and demonstrated that the languages we were looking at offered much
Background and Beginnings 1
We are reasonably smart but not exceptional programmers with several decades of professional experience amongst us. Our main working languages were C in the 1990s, Java in the very late 1990s and the first part of the 2000s, and Ruby(7) after that. Other developers on the team have experience in similar languages (such 1 Despite this paper having one author, I am reporting on the combined experiences of a multi-person developer and customer team. Thus, I use “we” throughout, except when relating distinctly personal experiences.
Permission to make digital or hard copies of all or part of this work for personal or classroom use is granted without fee provided that copies are not made or distributed for profit or commercial advantage and that copies bear this notice and the full citation on the first page. To copy otherwise, to republish, to post on servers or to redistribute to lists, requires prior specific permission and/or a fee. ICFP’09, August 31–September 2, 2009, Edinburgh, Scotland, UK. c 2009 ACM 978-1-60558-332-7/09/08. . . $5.00 Copyright
2 Particularly fascinating to us was, “In business, there is nothing more valuable than a technical advantage your competitors don’t understand.” We are still considering the importance of ourselves understanding the technical advantages we’ve gained.
185
to the platform facilities themselves (system calls and so on) is important. At times being able to do things such as set operatingsystem-specific socket options for individual network connections can make the difference between an application working well or poorly. In some cases, applications need to be able to control memory layout and alignment in order to marshal specific data structures used by the system or external libraries. In our case performance was an important consideration. The application reads and parses market data messages that arrive on a network connection and responds with orders. The response must occur within a few dozen milliseconds for the system to work reasonably well, and the faster the response, the greater the chance the system’s algorithms will be able to make profitable trades. Finally, though this is not true of all commercial developers, we prefer to use a free, open source implementation of a language, rather than commercial tools. There are two reasons for this beyond the purchase cost. First, we’ve found that commercial support for proprietary software is not significantly better, and is often worse, than community support for open source software under active development. Second, the access to source code and ability to rebuild it can be extremely helpful when it comes to debugging problems (the burden of which usually falls on the customer). Third, clients often feel more comfortable with open source when using niche products (as most functional language platforms are) as it ensures that they can have continued access to the tools needed to build their system, as well as the system itself.
more concision and a more mathematical notation than Java, and showed how this would enable him to more easily participate in the programming process.
2.
Selection of Language and Tools
2.1
Desired Language Features
The world of functional programming offers a wide, even bewildering variety of features and technologies, spread across many different languages. In some cases a language has has strong support for an imperative or object-oriented coding style, with merely the possibility of doing functional programming (such as JavaScript); in other cases one must abandon wholesale one’s non-functional style and adopt a completely different one. As we had already extensive experience with Java and Ruby and had become unhappy with the level of expressive power that either offered, we chose to pursue what we felt were more “advanced” language features, at the cost of an increased learning curve and greater risk. Functional language features of particular interest to us were a sophisticated type system (Hindley-Milner is the obvious example here), and advanced, modular structures for control flow. More generally, we were looking for minimal syntax, concise yet expressive code, powerful abstractions, and facilities that would allow us easily to use parallelism. The “concise yet expressive code” requirement was particularly important to us, both because we’ve found that reading and understanding existing code is a substantial part of the programming process, and because we wanted to be able to develop code that, at least for the parts in his domain, our client could read and perhaps modify. 2.2
2.3
Languages Examined
With the above criteria in mind, we looked at some of the more popular free functional language implementations available. Our ability to compare these was limited: we simply didn’t know enough about functional languages in general, and had no extensive experience with any functional language implementation. Thus, we were forced to rely on what we could learn from textbooks and information on the web. (Blog entries documenting interesting features of the various languages were particularly influential.) Especially, we had no way of judging some of the more esoteric and complex language features as we simply didn’t understand them. This no doubt skewed our evaluation process, but we saw no reasonable way of dealing with this other than spending several months building a non-trivial test application in each of several different languages. This point should be kept in mind by language promoters: the information you need to communicate to convince non-functional-programmers to try a language is of a substantially different sort than what sways those who are already familiar with at least one functional programming language. We looked most seriously at the Glasgow Haskell Compiler(2), Objective Caml(6), and Scala(8). (We considered looking at LISP and Scheme implementations, but these seemed to be both lacking in certain language features and we had (what are in retrospect perhaps undeserved) concerns about the available free implementations.) All three of these language implementations we examined share some features in common:
Considerations of Commercial Software Developers
As commercial software developers, there were also several other factors influencing our selection of tools. A primary criterion is reliability: bugs in the toolchain or runtime system can derail a project irrecoverably, and may not appear until well into development. Good support can help to mitigate these problems, but that still provides no guarantee that any particular issue can be solved in a timely manner. Generally, widespread use on a particular platform provides the best hope that most serious problems will have already been encountered and debugged. For developers with sufficient technical knowledge, having the source for the system can also provide the ability to debug problems in more detail than the systems supporters are perhaps willing to do, increasing the chances of finding a solution, even if the solution is implemented by someone else. The compiler or interpreter is only one part of a developer’s environment; just as important are the methods of code storage (flat files or otherwise), source code control systems, editors, build tools (such as make), testing frameworks, profilers, and other tools, many of these often home-grown. These represent a substantial investment in both learning and development time, and are usually important to the success of a project. Thus, being able to use a new language implementation with existing and familiar tools is a huge benefit. If the implementation is very nearly a drop-in replacement for a language implementation already in use (as with GHC or Objective Caml for GCC in typical Unix development projects, or F# in .NET environments), much of the previously-built development environment can be reused. If, on the other hand, everything needs to be replaced (as with some Smalltalk environments, or Lisp machines), this imposes a tremendously increased burden. For many commercial developers, integration with other code running on the platform (such as C libraries) and complete access
• expressive static type systems; • concise code, and syntax suited to building embedded domain-
specific languages without macros; • compilers known to be fairly reliable and produce reasonably
fast code; • tools that fit with our current Unix development systems; • the ability to interface with code compiled from other lan-
guages; and • an open source implementation under active development.
186
2.3.1
The Glasgow Haskell Compiler
with more due to appear, blog entries describing interesting uses of Haskell and various techniques were plentiful, and the #haskell channel on IRC was particularly responsive to questions.
Haskell was the language that most appealed to us; it seemed to have the most advanced set of features of any language out there. Especially, being able easily to guarantee the purity of parts of the code promised to make testing easier, which is an important point for us as we rely heavily on automated testing. The other significantly different language feature of Haskell, lazy evaluation, had mild appeal for potential performance benefits, but we really had no idea what the true ramifications of lazy evaluation were. The extensive use of monads was interesting to us, but, as with lazy evaluation, we didn’t know enough about it to have any sort of informed opinion. We simply believed what we’d read that monads were a good thing. There were things about Haskell we found easier to understand. Type classes we found relatively simple, and they seemed to offer more flexibility than the standard object-oriented inheritance-based approach. Two things we could understand very well about Haskell were that it was relatively popular, with a strong community, and that the Foreign Function Interface offered us an escape into other languages should we encounter any overwhelming problems, performance or otherwise. The books available for learning Haskell, though at times mystifying, seemed to provide the promise of wonderful things. The Haskell School of Expression(4), in particular, we found fascinating. There are several implementations of Haskell available; we chose the Glasgow Haskell Compiler(2) as that is widely recognized as the most mature Haskell compiler available. 2.3.2
• The compiler, while perhaps at the time not as good as the
Objective Caml compiler, appeared to be under more active development.
3.
Successes and Advantages
3.1
Concise and Readable Code
After a week or two of adjustment, we found Haskell syntax to be remarkably clean and expressive. An initial test was to work with the client to code some of the mathematical algorithms used by the trading system; many were straightforward translations of the mathematical equations into similar Haskell syntax. This obvious advantage of Haskell proved a particularly good selling point to the client early on in the process, reinforcing the idea that the client, though not a programmer, would be able easily to understand some of the more important domain-related aspects of his application. Another early area of work was the parser for the market data feed. Initially we used Parsec for this, and were quite impressed by how expressive Haskell and a combinator-based approach can be. However, we soon decided to experiment with writing our own parser, for two reasons. First, Parsec did not allow us to keep certain items of state that we felt we needed for error-checking and recovery purposes. Second, the version of Parsec that we were using at the time used String rather than ByteString, and some brief profiling experiments showed that the performance advantages of ByteString were considerable.3 As it turned out, after some study (the “Functional Parsers” chapter of Hutton’s Programming in Haskell(5) was particularly helpful here), we found that parsers in Haskell are particularly easy to write. Learning about control structures beyond simple recursion, particularly monadic ones, took considerably more time, but also proved fertile ground for finding ways to improve our code’s clarity and concision. After a year or so we are extremely happy with our improved ability (over object-oriented languages, and well beyond just parsers) to build combinators, manage state, and deal with complex control flow through monadic code. To summarize, though the learning effort for the various techniques available to us ranged from low (for mathematical formulae) to moderately high (monadic control structures), there seemed to be almost no area in which Haskell code was not more clear and considerably more concise than doing the equivalent in Ruby or, particularly, Java.
Objective Caml
To us, Objective Caml(6) appeared to be a more conservative alternative to GHC, offering many of GHC’s and Haskell’s features, but without being so radical as to introduce enforced purity or lazy evaluation. Advantages particular to the implementation were that it was well known, mature, reputedly produced very fast code, and we knew it to be used for large amounts of production code by other companies in the financial community. OCaml did have several things that put us off. The language syntax didn’t seem nearly as clean as Haskell, which was partially inherent (such as double-semicolons to end declarations) and partly due to the inability to overload functions (due to the lack of type classes). As well, the books we had available were just not as good. At that time we’d just finished reading a fairly recent book on OCaml that, unlike the Haskell books we’d read, did not show us any impressive new programming techniques but instead appeared to treat the language in a very imperative fashion.
3.2
The Type System and Unit Tests
We considered Scala(8) mainly because we thought we might have to use Java libraries. This turned out not to be the case, and as we otherwise preferred to run native code, we didn’t investigate the language very deeply.
It’s not unusual, when writing in languages with little compile-time type-checking such as Ruby, for about a third of our code to be unit tests. We had anticipated that we might write fewer unit tests in Haskell, but the extent to which the type system reduced the need for unit tests surprised us. As a comparison, in reasonably complex Ruby application of similar line count (though rather less
2.4
3 ByteString
2.3.3
Scala
Selection of Haskell
is, I think, from an academic point of view a rather trivial optimization of some fairly ordinary routines. But from our point of view it was a huge win, and we are eternally grateful to Don Stewart and Duncan Coutts for writing this library. This is worth considering when designing a new system: you need not immediately supply efficient things for basic commercial needs, but if you can give others the ability (through mechanisms such as the OverloadedStrings language extension) to easily bring those in later, you open up more possibilities for success in the commercial arena. Haskell has not been bad in this respect, but there are many times I’ve wished for improvements such as making String a typeclass.
After examining the three options above, we chose the Glasgow Haskell Compiler, version 6.8, as our development platform. We felt that GHC offered the following advantages: • Haskell appeared to be a more powerful and more interesting
language than Objective Caml. • The Haskell community offered good support and was (and
still is) growing. There were several good books available
187
functionality), we have about 5200 lines of production code and just over 1500 lines of unit tests. In contrast, in the version of the trading system as of early 2009, we had over 5600 lines of production code and less than 500 lines of unit tests. The functional tests show similar differences. Further, in most areas where we used the type system to show program correctness, we came out with much more confidence that the program was indeed correct than if we had used testing. Unlike tests, a good type system and compiler will often force the developer to deal with cases that might have been forgotten in testing, both during the initial development and especially when later modifying the code. On one last note, the difference in the amount of test code we used was far larger between Ruby and Haskell than between Ruby and Java, though Java, as with Haskell, also offers static type checking. We attribute this to a large difference in expressive power between the type systems of Haskell and Java.
allowed us to code this entirely in Haskell without having to write a single line of C. Further, we were able to make extensive use of the C header files supplied for the library, resorting only minimally to error-prone supplying of type information by hand. Being able to use the Haskell type checker to write C-like code was particularly enjoyable. Type-checking memory allocations and the like is a well known and impressive technique in the HindleyMilner type system community, but it wasn’t until we’d used it ourselves in anger that we realized that programming this close to the machine could be quite a relaxing thing. This was a significant change from any other language we’ve used, and from what we believe we would have needed to do in, say, Objective Caml. Every other foreign interface we’ve seen would have required us to write at least some code in C and would have provided us with significantly less type safety.
3.3
No development environment or set of tools is without its problems, and GHC turned out to be no exception. However, while the nature of the problems we ran into was different from other systems, the general number and level of difficulty of the problems was not significantly different from any other system, especially taking into account how different the language is. The largest problems for us were issues with learning the language itself, dealing with lazy evaluation, and the performance issue of space leaks.
4.
Speed
With the exception of space leak problems (see next section), speed was never an issue for us. The application is sensitive to how fast it can do the calculations to build a pricing model for the current market, but the code generated by GHC 6.8.3 turned out to be significantly faster than the client’s previous Java application. We’ve to this point seen no significant difference between the current system’s performance and what we believe we could achieve in C or C++. We use multi-core systems, and make extensive use of multithreaded code for both concurrency and parallelism. This has worked well for us. However, when used for parallelism, with lazy evaluation one has to be careful about in which thread computations are really occurring: i.e., that one is sending a result, and not an unevaluated thunk, to another thread. This has brought up problems and used solutions similar in style to the space leak issues. We have yet to make extensive use of explicitly parallel computation based on the par function. However, it seems clear that if and when we move in to this area, implementation will be considerably simpler than when using a thread-based model. Haskell’s ”purity by default” model helps greatly here. 3.4
4.1
Language Issues
Haskell’s syntax is quite simple and consistent, and this is great advantage. However syntax is a relatively small part of learning a language, even in languages where it is significantly more complex. To use a language well, and take good advantage of it, one must also learn its structures, idioms and style. Haskell is well known as a language that’s very different from others in this regard, and even after many months of programming in it, we still don’t feel we’ve progressed particularly far in this direction, aside from the use of monads for handling state and control flow. (For example, we use applicative style in very limited ways, and as yet make no use of Control.Applicative.) We found that to use many common Haskell structures, such as functors, monoids and monads, we needed to think in significantly different ways than we did in other languages, even other functional languages such as Scheme. Further, the level of abstract thought required to understand these structures (in great part due to their generality) is noticeably higher than in other languages, and in our experience not in the background of many typical commercial programmers. This is not an insurmountable problem, given appropriate learning materials: we built our first monadic parser from scratch after only a few days of study. But learning these things, especially on one’s own, can be pretty rough going. This is mitigated to some degree by being able to fall back easily to more commonly known structures from other languages. We spent a couple of days as an extended interview working with a programmer unfamiliar with Haskell but with extensive experience in LISP, and while the code we produced made little use of typical Haskell idioms, it was certainly clear and workable.
Portability
One pleasant surprise was that, once we’d made a few modifications to our build system, building and running our application under Microsoft Windows was no trouble at all. While not an initial requirement, this was a nice facility to have as it saved our client setting up a separate Unix machine to run the simulator for himself. Eventually, we ended up being able to write in Haskell a small amount of Windows code we’d initially planned to write in C++, which saved us considerable time and effort (see below). 3.5
Problems and Disadvantages
The Foreign Function Interface
One part of our application involved interfacing with the Microsoft Windows DDE facility in order to transfer hundreds of data values to Excel to be displayed and updated several times per second. We had originally planned to write this this in C++, but in view of the portability we’d discovered, we decided see how much of this we could do in Haskell. This involved a non-trivial interface with a Microsoft C library. In addition to simple calls into C code, we had to deal with lowlevel data structures involving machine words that combined bit fields and integers of non-standard bit sizes, special memory allocation and deallocation schemes specific to the library, and callbacks from the library back into our code. This might well have been the biggest and most pleasant surprise we encountered: GHC’s excellent Foreign Function Interface
4.2
Refactoring
One of our reasons for doing extensive automated testing is to support refactoring, a technique which we use extensively. As compared to Ruby, refactoring “in the small,” that is, within a single module or a small change crossing a few modules, was more difficult. The main issue was that where, with a language such as Ruby, one can change a small handful of functions and leave the
188
rest “broken” while testing the change, in Haskell one must fix every function in the module before even being able to compile. There are certain techniques to help work around this, such as copying the module and removing the code not currently of interest (which is tedious), or minimizing the use of type declarations (which doesn’t always help, and brings its own problems), but we didn’t find these to be very satisfactory. We feel that a good refactoring tool could significantly ease this problem, but there seems nothing like this in common use in the Haskell community at the moment. That said, the major refactorings we do on a regular basis (“major” ones being restructuring that crosses many modules and moves significant amounts of code between them) were less affected by this problem, and did not seem to take significantly longer than implementing the same sort of change in any other language. As well, being able to do more work with the type checker and less with testing increased our confidence that our refactorings had not broken parts of the system.
over time. This would allow us to monitor the distribution of work to see where more strictness needs to be applied in order to make the best use of our multi-core machines. This needs to be available for non-profiled builds so that we can monitor how the application runs in the real environment, as well as when it’s specially built for profiling. We feel that this would be a great step forward in fulfilling pure functional programming’s promise of much easier use of multi-core CPUs than conventional languages. A last note about a particular problem for us with GHC: profiling builds must run using the non-parallel runtime. For an application that requires more than a full core even for builds not slowed down by profiling, this can at times be entirely useless: the problems that show up in the profile output are due entirely to the lack of CPU power available to run the application within its normal time constraints. We are contemplating modifying our stock exchange simulator and trading system to run at a small fraction of “real-time” speed in order to see if this might provide more realistic profiling results.
4.3
4.4
Profiling Tools, and Deficiencies Thereof
Lazy Evaluation and Space Leaks
Lazy evaluation turned out to have good and bad points in the end. However, it caused, and continues to cause, us pain in two particular areas. The first is that, when distributing computation amongst multiple co-operating threads, it’s important to ensure that data structures passed between threads are evaluated to head normal form when necessary before being used by the receiving thread. Initially we found figuring out how to do this to be difficult; even determining when this is necessary can be a challenge in itself. (One clue is a large amount of heap use connected with communications queues between threads, as the sending thread overruns the receiver’s ability to evaluate the thunks it’s receiving.) A profiler that did not slow down the program and that could help with this would be highly welcome. The second is the dreaded “space leak,” when unevaluated computations retain earlier versions of data in the heap and cause it to grow to enormous proportions, slowing and eventually halting the program as it consumes all memory and swap space. This proved to be a particular problem in our application, which is essentially several loops (processing messages and doing calculations) executed millions of times over the course of a many-hour run. The profiler that comes with GHC is of more help here, but we have yet to work out a truly satisfactory way of dealing with this. A particularly common cause of space leaks for us was when using data structures (such as lists and Data.Map) that have many small changes over time. As one inserts and removes data, old, nolonger-referenced versions of the data structure are kept around to enable the evaluation of thunks that will create the new version of the data structure. As mentioned previously, na¨ıve approaches to the problem can backfire, and better approaches were not always obvious. For Data.Map and similar structures, we resorted to doing an immediate lookup and forced evaluation of data just inserted; this seemed to fix the problem while avoiding the massive performance penalty of doing an rnf on the root of the data structure. Another issue relates to interactive programs. Certain lazy idioms (such as using the getContents function) can effectively deadlock such programs, rendering them unresponsive. This is not immediately obvious to programmers new to lazy evaluation, and caused us early on to spend some figuring out what was going on. Knowing what we know now, we are still hesitant about the value of lazy evaluation; every time we feel we’ve finally got a good handle on it, another problem seems to crop up. We feel that the subject needs, at the very least, a good book that would both cover all of the issues well and help the reader develop a good intuition for the behaviour of lazily-evaluated systems.
Due especially to lazy evaluation (of which more is mentioned later), understanding the runtime behaviour of one’s application is rather important when working in Haskell. GHC’s profiling tools are good—certainly as good as anything in Java—but unfortunately not good enough that we don’t have some significant complaints. First, they are rather subtle: it takes time and some careful examination of the documentation and the output to learn to use them well (though this is very often true of any profiling tool). We have found that, as of mid-2009, there are no good tutorials available for this; frustrating and time-consuming experimentation has been the rule. Better descriptions of the output of the summarization tools (such as hp2ps, which generates a graph of heap usage from raw profile data) may not be the only solution to this: providing more documentation of the raw profiling data itself might not only enable developers to build their own profile analysis tools, but provide more insight as to just what’s going on inside the runtime system. One of the major problems, directly connected to lazy evaluation, is that where the work happens is quite a slippery concept. One module may define and apply all of the functions for the work to be done, but in actuality leave only a few thunks in the heap: the real work of the computation occurs in some other place where the data are used. If several other modules use the results of, say, a parser, which module or function actually ends up doing the computational work of the parsing (evaluating the thunks) can change from run to run of the program. This problem is only exacerbated by doing work in multiple threads. When one hopes to spread the work more or less evenly amongst all the threads in the application, inter-thread communication mechanisms that as happily transfer thunks as they do data are more of a liability than an asset. It’s quite possible to have a final consumer of “work” actually doing all of the work that one wanted to have done in several other threads running on different cores. Na¨ıve approaches to fixing the problem can easily backfire. At one point we made some of our data structures members of the NFData class from Control.Parallel.Strategies and used the rnf function at appropriate points to ensure that evaluation had been fully forced. This turned out to do more harm than good, causing the program’s CPU usage shoot through the roof. While we didn’t investigate this in detail, we believe that the time spent repeatedly traversing large data structures, even when already mostly evaluated, was the culprit. (This would certainly play havoc with the CPU’s cache.) We would particularly like to have a way at runtime to monitor the CPU usage of each individual Haskell thread within our application no matter which operating system threads it might run on
189
5.
References
Conclusions
[1] Friedman, Daniel P., and Felleisen, Matthias, The Little Schemer, Fourth Edition. MIT Press, 1996. ISBN-13: 978-0-262-56099-3. [2] The Glasgow Haskell Compiler. http://www.haskell.org/ghc/ [3] Graham, Paul, “Beating the Averages”, from Hackers and Painters O’Reilly, 2004, ISBN-13: 978-0-596-00662-4. Also available from http://paulgraham.com/avg.html [4] Hudak, Paul, The Haskell School of Expression: Learning Functional Programming Through Multimedia. Cambridge University Press, 2000. ISBN-13: 978-0-521-64408-2. [5] Hutton, Graham, Programming in Haskell. Cambridge University Press, 2007. ISBN-13: 978-0-521-69269-4. [6] Objective Caml. http://caml.inria.fr/ocaml/index.en.html [7] Ruby Programming Language. http://www.ruby-lang.org/en/ [8] The Scala Programming Language. http://www.scala-lang.org/ [9] Duncan Coutts, Don Stewart and Roman Leshchinskiy, “Rewriting Haskell Strings.” http://www.cse.unsw.edu.au/ dons/papers/CSL06.html
Our experience with GHC has shown that, while it has its quirks and issues, these are no worse than those of other commonly-used programming language implementations when used for commercial software development. However, some of the issues are different enough that one should be prepared to spend a little more time learning how to deal with them than when moving between more similar systems. We found significant advantages to using Haskell, and it’s clear to us that there is much more expressive power available of which we’ve yet to make use. Learning to take advantage of Haskell takes a fair amount of work, but benefits are seen fairly quickly and continuously as one improves. We were lucky with our choice of Haskell and GHC and, in light of our experience, we would make the same choice again. However, we note that, given our lack of experience with other functional languages and platforms, we cannot really say whether or not it is significantly better than, say, OCaml or Scala. It is our opinion that, overall, our switch to a functional language in a commercial environment has been successful, and we are convinced we will continue to see further benefit over the long term.
190
Beautiful Differentiation Conal M. Elliott LambdaPix
[email protected]
Abstract
d (u + v) d (u · v) d (−u) d (eu ) d (log √u) d ( u) d (sin u) d (cos u) d (sin−1 u) d (cos−1 u) d (tan−1 u) d (sinh u) d (cosh u) d (sinh−1 u) d (cosh−1 u) d (tanh−1 u)
Automatic differentiation (AD) is a precise, efficient, and convenient method for computing derivatives of functions. Its forwardmode implementation can be quite simple even when extended to compute all of the higher-order derivatives as well. The higherdimensional case has also been tackled, though with extra complexity. This paper develops an implementation of higher-dimensional, higher-order, forward-mode AD in the extremely general and elegant setting of calculus on manifolds and derives that implementation from a simple and precise specification. In order to motivate and discover the implementation, the paper poses the question “What does AD mean, independently of implementation?” An answer arises in the form of naturality of sampling a function and its derivative. Automatic differentiation flows out of this naturality condition, together with the chain rule. Graduating from first-order to higher-order AD corresponds to sampling all derivatives instead of just one. Next, the setting is expanded to arbitrary vector spaces, in which derivative values are linear maps. The specification of AD adapts to this elegant and very general setting, which even simplifies the development.
≡ ≡ ≡ ≡ ≡ ≡ ≡ ≡ ≡ ≡ ≡ ≡ ≡ ≡ ≡ ≡
du + dv dv · u + du · v −d u d u · eu d u/u √ d u/(2 · u) d u · cos u d u ·√ (− sin u) d u/ √ 1 − u2 −d u/ 1 − u2 d u/(u2 + 1) d u · cosh u d u ·√ sinh u u2 + 1 d u/ √ −d u/ u2 − 1 d u/(1 − u2 )
Figure 1. Some rules for symbolic differentiation
Categories and Subject Descriptors G.1.4 [Mathematics of Computing]: Numerical Analysis—Quadrature and Numerical Differentiation
One differentiation method is numeric approximation, using simple finite differences. This method is based on the definition of (scalar) derivative:
General Terms
f (x + h) − f x (1) h (The left-hand side reads “the derivative of f at x ”.) The approximation method uses f (x + h) − f x df x ≈ h for a small value of h. While very simple, this method is often inaccurate, due to choosing either too large or too small a value for h. (Small values of h lead to rounding errors.) More sophisticated variations improve accuracy while sacrificing simplicity. A second method is symbolic differentiation. Instead of using the limit-based definition directly, the symbolic method uses a collection of rules, such as those in Figure 1 There are two main drawbacks to the symbolic approach to differentiation. One is simply the inconvience of symbolic methods, requiring access to and transformation of the source code of computation, and placing restrictions on that source code. A second drawback is that implementations tend to be quite expensive and in particular perform redundant computation. A third method is the topic of this paper (and many others), namely automatic differentiation (also called “algorithmic differentiation”), or “AD”. There are forward and reverse variations (“modes”) of AD, as well as mixtures of the two. This paper considers only the forward-mode. The idea of AD is to simultaneously manipulate values and derivatives. Overloading of the standard numerical operations makes this combined manipulation as
Keywords
1.
Algorithms, Design, Theory
d f x ≡ lim
h→0
Automatic differentiation, program derivation
Introduction
Derivatives are useful in a variety of application areas, including root-finding, optimization, curve and surface tessellation, and computation of surface normals for 3D rendering. Considering the usefulness of derivatives, it is worthwhile to find software methods that are • simple to implement, • simple to prove correct, • convenient, • accurate, • efficient, and • general.
Permission to make digital or hard copies of all or part of this work for personal or classroom use is granted without fee provided that copies are not made or distributed for profit or commercial advantage and that copies bear this notice and the full citation on the first page. To copy otherwise, to republish, to post on servers or to redistribute to lists, requires prior specific permission and/or a fee. ICFP’09, August 31–September 2, 2009, Edinburgh, Scotland, UK. c 2009 ACM 978-1-60558-332-7/09/08. . . $5.00 Copyright
191
• It is simple to verify informally, because of its similarity to the
data D a = D a a deriving (Eq, Show )
differentiation laws.
constD :: Num a ⇒ a → D a constD x = D x 0
• It is convenient to use, as shown with f1 above. • It is accurate, as shown above, producing exactly the same result
idD :: Num a ⇒ a → D a idD x = D x 1
as the symbolic differentiated code, f2 . • It is efficient, involving no iteration or redundant computation.
instance Num a ⇒ Num (D a) where fromInteger x = constD (fromInteger x ) D x x 0 + D y y 0 = D (x + y) (x 0 + y 0 ) D x x 0 ∗ D y y 0 = D (x ∗ y) (y 0 ∗ x + x 0 ∗ y) negate (D x x 0 ) = D (negate x ) (negate x 0 ) signum (D x ) = D (signum x ) 0 abs (D x x 0 ) = D (abs x ) (x 0 ∗ signum x )
The formulation in Figure 2 does less well with generality: • It computes only first derivatives. • It applies (correctly) only to functions over a scalar (one-
dimensional) domain. Moreover, proving correctness is hampered by lack of a precise specification. Later sections will address these shortcomings.
instance Fractional x ⇒ Fractional (D x ) where fromRational x = constD (fromRational x ) recip (D x x 0 ) = D (recip x ) (x 0 / sqr x )
This paper’s technical contributions include the following. • A prettier formulation of first-order and higher-order forward-
sqr :: Num a ⇒ a → a sqr x = x ∗ x
mode AD using function-based overloading (Sections 2, 3 and 4).
instance Floating x ⇒ Floating (D x ) where π = constD π exp (D x x 0 ) = D (exp x ) (x 0 ∗ exp x ) log (D x x 0 ) = D (log x ) (x 0 / x ) sqrt (D x x 0 ) = D (sqrt x ) (x 0 / (2 ∗ sqrt x )) sin (D x x 0 ) = D (sin x ) (x 0 ∗ cos x ) cos (D x x 0 ) = D (cos x ) (x 0 ∗ (−sin x )) asin (D x x 0 ) = D (asin x ) (x 0 / sqrt (1 − sqr x )) acos (D x x 0 ) = D (acos x ) (x 0 / (−sqrt (1 − sqr x ))) ...
• A simple formal specification for AD (Section 5). • A systematic derivation of first-order and higher-order forward-
mode AD from the specification (Sections 5.1 and 6). • Reformulation of AD to general vector spaces including (but
not limited to) Rm → Rn , from the perspective of calculus on manifolds (CoM) (Spivak 1971), and adaptation of the AD derivation to this new setting (Section 10). • General and efficient formulations of linear maps and bases of
vector spaces (using associated types and memo tries), since the notion of linear map is at the heart of CoM (Appendix A).
2.
Figure 2. First-order, scalar, functional automatic differentiation
Friendly and precise
To start, let’s make some cosmetic improvements, which will be carried forward to the more general formulations as well. Figure 1 has an informality that is is typical of working math notation, but we can state these properties more precisely. For now, give differentiation the following higher-order type:
convenient and elegant as manipulating values without derivatives. Moreover, the implementation of AD can be quite simple as well. For instance, Figure 2 gives a simple, functional (foward-mode) AD implementation, packaged as a data type D and a collection of numeric type class instances. Every operation acts on a regular value and a derivative value in tandem. (The derivatives for abs and signum need more care at 0.) As an example, define
d :: (a → a) → (a → a)
-- first attempt
Then Figure 1 can be made more precise. For instance, the sum rule is short-hand for d (λx → u x + v x ) ≡ λx → d u x + d v x
f1 :: Floating a ⇒ a → a f1 z = sqrt (3 ∗ sin z )
and the log rule means d (λx → log (u x )) ≡ λx → d u x / u x
and try it out in GHCi:
These more precise formulations are tedious to write and read. Fortunately, there is an alternative to replacing Figure 1 with more precise but less human-friendly forms. We can instead make the human-friendly form become machine-friendly. The trick is to add numeric overloadings for functions, so that numeric operations apply point-wise. For instance,
*Main> f1 (D 2 1) D 1.6516332160855343 (-0.3779412091869595) To test correctness, here is a symbolically differentiated version: f2 :: Floating a ⇒ a → D a f2 x = D (f1 x ) (3 ∗ cos x / (2 ∗ sqrt (3 ∗ sin x )))
u + v ≡ λx → u x + v x log u ≡ λx → log (u x )
Try it out in GHCi: *Main> f2 2 D 1.6516332160855343 (-0.3779412091869595)
Then the “informal” laws in Figure 1 turn out to be well-defined and exactly equivalent to the “more precise” long-hand versions above. The Functor and Applicative (McBride and Paterson 2008) instances of functions (shown in Figure 3) come in quite handy. Figure 4 shows the instances needed to make Figure 1 welldefined and correct exactly as stated, by exploiting the Functor and Applicative instances in Figure 3. In fact, these instances work
This AD implementation satisfies most of our criteria very well: • It is simple to implement. The code matches the familiar laws
given in Figure 1. There are, however, some stylistic improvements to be made in Section 4.
192
≡ { d sin ≡ cos } (cos ◦ u) ∗ d u ≡ { cos on functions } cos u ∗ d u
instance Functor ((→) t) where fmap f g = f ◦ g instance Applicative ((→) t) where pure = const f ~g = λt → (f t) (g t)
The first two rules cannot be explained in terms of the scalar chain rule, but can be explained via the generalized chain rule in Section 10. We can implement the scalar chain rule simply, via a new infix operator, (./), whose arguments are a function and its derivative.
Consequently, liftA2 h u v ≡ λx → h (u x ) (v x ) liftA3 h u v w ≡ λx → h (u x ) (v x ) (w x ) ...
infix 0 ./ (./) :: Num a ⇒ (a → a) → (a → a) → (D a → D a) (f ./ f 0 ) (D a a 0 ) = D (f a) (a 0 ∗ f 0 a)
Figure 3. Standard Functor and Applicative instances for functions instance Num fromInteger negate (+) (∗) abs signum
This chain rule removes repetition from our instances. For instance, instance Floating x ⇒ Floating (D x ) where π =D π0 exp = exp ./ exp log = log ./ recip sqrt = sqrt ./ λx → recip (2 ∗ sqrt x ) sin = sin ./ cos cos = cos ./ λx → −sin x asin = asin ./ λx → recip (sqrt (1 − sqr x )) acos = acos ./ λx → −recip (sqrt (1 − sqr x )) ...
b ⇒ Num (a → b) where = pure ◦ fromInteger = fmap negate = liftA2 (+) = liftA2 (∗) = fmap abs = fmap signum
instance Fractional b ⇒ Fractional (a → b) where fromRational = pure ◦ fromRational recip = fmap recip
4.
instance Floating b ⇒ Floating (a → b) where π = pure π sqrt = fmap sqrt exp = fmap exp log = fmap log sin = fmap sin ...
5. Figure 4. Numeric overloadings for function types
What is automatic differentiation, really?
The preceding sections describe what AD is, informally, and they present plausible implementations. Let’s now take a deeper look at AD, in terms of three questions:
for any applicative functor—a point that will become important in Section 10. We’ll soon see how to exploit this simple, precise notation to improve the style of the definitions from Figure 2.
3.
Prettier derivatives via function overloading
Section 2 gave numeric overloadings for functions in order to make the derivative laws in Figure 1 precise, while retaining their simplicity. We can use these overloadings to make the derivative implementation simpler as well. With the help of (./) and the overloadings in Figure 4, the code in Figure 2 can be simplified to that in Figure 5.
1. What does it mean, independently of implementation? 2. How do the implementation and its correctness flow gracefully from that meaning? 3. Where else might we go, guided by answers to the first two questions?
A scalar chain rule 0
Many of the laws in Figure 1 look similar: d (f u) = d u ∗ f u for some function f 0 . The f 0 is not just some function; it is the derivative of f . The reason for this pattern is that these laws follow from the scalar chain rule for derivatives.
5.1
A model for automatic differentiation
How do we know whether this AD implementation is correct? We can’t even begin to address this question until we first answer a more fundamental one: what exactly does its correctness mean? In other words, what specification must our implementation obey? AD has something to do with calculating a function’s values and derivative values simultaneously, so let’s start there.
d (g ◦ f ) x ≡ d g (f x ) ∗ d f x Using the (∗) overloading in Figure 4, the chain rule can also be written as follows: d (g ◦ f ) ≡ (d g ◦ f ) ∗ d f
toD :: (a → a) → (a → D a) toD f = λx → D (f x ) (d f x )
All but the first two rules in Figure 1 then follow from the chain rule. For instance,
Or, in point-free form,
d (sin u) ≡ { sin on functions } d (sin ◦ u) ≡ { reformulated chain rule } (d sin ◦ u) ∗ d u
toD f = liftA2 D f (d f ) thanks to the Applicative instance in Figure 3. We have no implementation of d , so this definition of toD will serve as a specification, not an implementation.
193
instance Num a ⇒ Num (D a) where fromInteger = constD ◦ fromInteger D x0 x 0 + D y0 y 0 = D (x0 + y0 ) (x 0 + y 0 ) 0 0 x @(D x0 x ) ∗ y@(D y0 y ) = D (x0 ∗ y0 ) (x 0 ∗ y + x ∗ y 0 ) negate = negate ./ −1 abs = abs ./ signum signum = signum ./ 0 instance Fractional a ⇒ Fractional (D a) where fromRational = constD ◦ fromRational recip = recip ./ −sqr recip
5.2.1
Constants and identity function
Value/derivative pairs for constant functions and the identity function are specified as such: constD :: Num a ⇒ a → D a idD :: Num a ⇒ a → D a constD x ≡ toD (const x ) ⊥ idD ≡ toD id To derive implementations, expand toD and simplify. constD x ≡ { specification } toD (const x ) ⊥ ≡ { definition of toD } D (const x ⊥) (d (const x ) ⊥) ≡ { definition of const and its derivative } Dx 0
instance Floating a ⇒ Floating (D a) where π = constD π exp = exp ./ exp log = log ./ recip sqrt = sqrt ./ recip (2 ∗ sqrt) sin = sin ./ cos cos = cos ./ −sin asin = asin ./ recip (sqrt (1 − sqr )) acos = acos ./ recip (−sqrt (1 − sqr )) atan = atan ./ recip (1 + sqr ) sinh = sinh ./ cosh cosh = cosh ./ sinh asinh = asinh ./ recip (sqrt (1 + sqr )) acosh = acosh ./ recip (−sqrt (sqr − 1)) atanh = atanh ./ recip (1 − sqr )
idD x ≡ { specification } toD (id x ) ≡ { definition of toD } D (id x ) (d id x ) ≡ { definition of id and its derivative } Dx 1 In (Karczmarczuk 2001) and elsewhere, idD is called dVar and is sometimes referred to as the “variable” of differentiation, a term more suited to symbolic differentiation than to AD. 5.2.2
Figure 5. Simplified derivatives using the scalar chain rule and function overloadings
Addition
Specify addition on D by requiring that toD preserves its structure: toD (u + v ) ≡ toD u + toD v Expand toD, and simplify both sides, starting on the left: toD (u + v ) ≡ { definition of toD } liftA2 D (u + v ) (d (u + v )) ≡ { d (u + v ) from Figure 1 } liftA2 D (u + v ) (d u + d v ) ≡ { liftA2 on functions from Figure 3 } λx → D ((u + v ) x ) ((d u + d v ) x ) ≡ { (+) on functions from Figure 4 } λx → D (u x + v x ) (d u x + d v x )
Since AD is structured as type class instances, one way to specify its semantics is by relating it to a parallel set of standard instances, by a principle of type class morphisms, as described in (Elliott 2009c,b), which is to say that the interpretation preserves the structure of every method application. For AD, the interpretation function is toD. The Num, Fractional , and Floating morphisms provide the specifications of the instances: toD toD toD toD toD
(u + v ) ≡ toD u + toD v (u ∗ v ) ≡ toD u ∗ toD v (negate u) ≡ negate (toD u) (sin u) ≡ sin (toD u) (cos u) ≡ cos (toD u) ...
Then start over with the right-hand side: toD u + toD v ≡ { (+) on functions from Figures 3 and 4 } λx → toD u x + toD v x ≡ { definition of toD } λx → D (u x ) (d u x ) + D (v x ) (d v x )
Note here that the numeric operations are applied to values of type a → a on the left, and to values of type a → D a on the right. These (morphism) properties exactly define correctness of any implementation of AD, answering the first question:
We need a (+) on D that makes these two final forms equal, i.e., λx → D (u x + v x ) (d u x + d v x ) ≡ λx → D (u x ) (d u x ) + D (v x ) (d v x )
What does it mean, independently of implementation? 5.2
An easy choice is
Deriving an AD implementation
D a a 0 + D b b 0 = D (a + b) (a 0 + b 0 )
Equipped with a simple, formal specification of AD (numeric type class morphisms), we can try to prove that the implementation above satisfies the specification. Better yet, let’s do the reverse, using the morphism properties to derive (discover) the implementation, and prove it correct in the process. The derivations will then provide a starting point for more ambitious forms of AD.
This definition provides the missing link, completing the proof that toD (u + v ) ≡ toD u + toD v The multiplication case is quite similar (Elliott 2009a).
194
5.2.3
liftA2 D (u + v ) (toD (d (u + v ))) ≡ { d (u + v ) } liftA2 D (u + v ) (toD (d u + d v )) ≡ { induction for toD / (+) } liftA2 D (u + v ) (toD (d u) + toD (d v )) ≡ { definition of liftA2 and (+) on functions } λx → D (u x + v x ) (toD (d u) x + toD (d v ) x )
Sine
The specification: toD (sin u) ≡ sin (toD u) Simplify the left-hand side: toD (sin u) ≡ { definition of toD } liftA2 D (sin u) (d (sin u)) ≡ { d (sin u) } liftA2 D (sin u) (d u ∗ cos u) ≡ { liftA2 on functions } λx → D ((sin u) x ) ((d u ∗ cos u) x ) ≡ { sin, (∗) and cos on functions } λx → D (sin (u x )) (d u x ∗ cos (u x ))
and then the right: toD u + toD v ≡ { (+) on functions } λx → toD u x + toD v x ≡ { definition of toD } λx → D (u x ) (toD (d u x )) + D (v x ) (toD (d v x )) Again, we need a definition of (+) on D that makes the LHS and RHS final forms equal, i.e.,
and then the right:
λx → D (u x + v x ) (toD (d u) x + toD (d v ) x ) ≡ λx → D (u x ) (toD (d u) x ) + D (v x ) (toD (d v ) x )
sin (toD u) ≡ { sin on functions } λx → sin (toD u x ) ≡ { definition of toD } λx → sin (D (u x ) (d u x ))
Again, an easy choice is D a0 a 0 + D b0 b 0 = D (a0 + b0 ) (a 0 + b 0 )
So a sufficient definition is
The “induction” step above can be made more precise in terms of fixed-point introduction or the generic approximation lemma (Hutton and Gibbons 2001). Crucially, the morphism properties are assumed more deeply inside of the representation.
sin (D a a 0 ) = D (sin a) (a 0 ∗ cos a) Or, using the chain rule operator, sin = sin ./ cos
6.2
Simplifying on the left:
The whole implementation can be derived in exactly this style, answering the second question:
toD (u ∗ v ) ≡ { definition of toD } liftA2 D (u ∗ v ) (toD (d (u ∗ v ))) ≡ { d (u ∗ v ) } liftA2 D (u ∗ v ) (toD (d u ∗ v + d v ∗ u)) ≡ { induction for toD / (+) } liftA2 D (u ∗ v ) (toD (d u ∗ v ) + toD (d v ∗ u)) ≡ { induction for toD / (∗) } liftA2 D (u ∗ v ) (toD (d u) ∗ toD v + toD (d v ) ∗ toD u) ≡ { liftA2 , (∗), (+) on functions } λx → liftA2 D (u x ∗ v x ) (toD (d u) x ∗ toD v x + toD (d v ) x ∗ toD u x )
How does the implementation and its correctness flow gracefully from that meaning?
6.
Higher-order derivatives
Let’s now turn to the third question: Where else might we go, guided by answers to the first two questions? Our next destination will be higher-order derivatives, followed in Section 10 by derivatives over higher-dimensional domains. Jerzy Karczmarczuk (2001) extended the D representation above to an infinite “lazy tower of derivatives”.
and then on the right:
data D a = D a (D a)
toD u ∗ toD v ≡ { definition of toD } liftA2 D u (toD (d u)) ∗ liftA2 D v (toD (d v )) ≡ { liftA2 and (∗) on functions } λx → D (u x ) (toD (d u) x ) ∗ D (v x ) (toD (d v ) x )
The toD function easily adapts to this new D type: toD :: (a → a) → (a → D a) toD f x = D (f x ) (toD (d f ) x ) or
A sufficient definition is
toD f = liftA2 D f (toD (d f ))
a@(D a0 a 0 ) ∗ b@(D b0 b 0 ) = D (a0 + b0 ) (a 0 ∗ b + b 0 ∗ a)
The definition of toD comes from simplicity and type-correctness. Similarly, let’s adapt the previous derivations and see what arises. 6.1
Multiplication
because toD u x ≡ D (u x ) (toD (d u) x ) toD v x ≡ D (v x ) (toD (d v ) x )
Addition
Specification:
Note the new element here. The entire D value (tower) is used in building the derivative.
toD (u + v ) ≡ toD u + toD v Simplify the left-hand side:
6.3
toD (u + v ) ≡ { definition of toD }
Sine
As usual sin shows a common pattern that applies to other unary functions. Simplifying on the left-hand side:
195
8.
toD (sin u) ≡ { definition of toD } liftA2 D (sin u) (toD (d (sin u))) ≡ { d (sin u) } liftA2 D (sin u) (toD (d u ∗ cos u)) ≡ { induction for toD / (∗) } liftA2 D (sin u) (toD (d u) ∗ toD (cos u)) ≡ { induction for toD / cos } liftA2 D (sin u) (toD (d u) ∗ cos (toD u)) ≡ { liftA2 , sin, cos and (∗) on functions } λx → D (sin (u x )) (toD (d u) x ∗ cos (toD u x ))
Section 6 showed how easily and beautifully one can construct an infinite tower of derivative values in Haskell programs, while computing plain old values. The trick (from (Karczmarczuk 2001)) was to overload numeric operators to operate on the following (co)recursive type: data D b = D b (D b)
and then the right: sin (toD u) ≡ { definition of toD } sin (liftA2 D u (toD (d u))) ≡ { liftA2 and sin on functions } λx → sin (D (u x ) (toD (d u) x )) To make the left and right final forms equal, define sin a@(D a0 a 0 ) ≡ D (sin a0 ) (a 0 ∗ cos a) 6.4
A higher-order, scalar chain rule
The derivation above for sin shows the form of a chain rule for scalar derivative towers. It is very similar to the formulation in Section 3. The only difference are that the second argument (the derivative) gets applied to the whole tower instead of a regular value, and so has type D a → D a instead of a → a. infix 0 ./ (./) :: (Num a) ⇒ (a → a) → (D a → D a) → (D a → D a) (f ./ f 0 ) a@(D a0 a 0 ) = D (f a0 ) (a 0 ∗ f 0 a) With this new definition of (./), all of the chain-rule-based definitions in Figure 5 (first-order derivatives) carry over without change to compute infinite derivative towers. For instance,
This representation, however, works only when differentiating functions from a one-dimensional domain. The reason for this limitation is that only in those cases can the type of derivative values be identified with the type of regular values. Consider a function f :: R2 → R. The value of f at a domain value (x , y) has type R, but the derivative of f consists of two partial derivatives. Moreover, the second derivative consists of four partial second-order derivatives (or three, depending how you count). A function f :: R2 → R3 also has two partial derivatives at each point (x , y), each of which is a triple. That pair of triples is commonly written as a three-by-two matrix. Each of these situations has its own derivative shape and its own chain rule (for the derivative of function compositions), using plainold multiplication, scalar-times-vector, vector-dot-vector, matrixtimes-vector, or matrix-times-matrix. Second derivatives are more complex and varied. How many forms of derivatives and chain rules are enough? Are we doomed to work with a plethora of increasingly complex types of derivatives, as well as the diverse chain rules needed to accommodate all compatible pairs of derivatives? Fortunately, not. There is a single, simple, unifying generalization. By reconsidering what we mean by a derivative value, we can see that these various forms are all representations of a single notion, and all the chain rules mean the same thing on the meanings of the representations. Let’s now look at unifying view of derivatives, which is taken from calculus on manifolds (Spivak 1971). To get an intuitive sense of what’s going on with derivatives in general, we’ll look at some examples. 8.1
instance Floating a ⇒ Floating (D a) where exp = exp ./ exp log = log ./ recip sqrt = sqrt ./ recip (2 ∗ sqrt) sin = sin ./ cos cos = cos ./ −sin ...
One dimension
Start with a simple function on real numbers: f1 :: R → R f1 x = x 2 + 3 ∗ x + 1 Writing the derivative of a function f as d f , let’s now consider the question: what is d f1 ? We might say that d f1 x = 2 ∗ x + 3
Now the operators and literals on the right of (./) are overloaded for the type D a → D a. For instance, in the definition of sqrt,
so e.g., d f1 5 = 13. In other words, f1 is changing 13 times as fast as its argument, when its argument is passing 5. Rephrased yet again, if dx is a very tiny number, then f1 (5 + dx ) − f1 5 is very nearly 13 ∗ dx . If f1 maps seconds to meters, then d f1 5 is 13 meters per second. So already, we can see that the range of f (meters) and the range of d f (meters/second) disagree.
2 :: D a → D a recip :: (D a → D a) → (D a → D a) (∗) :: (D a → D a) → (D a → D a) → (D a → D a)
7.
What is a derivative, really?
8.2
Optimizing zeros
Two dimensions in and one dimension out
As a second example, consider a two-dimensional domain:
The derivative implementations above are simple and powerful, but have an efficiency problem. For polynomial functions (constant, linear, quadratic, etc), all but a few derivatives are zero. Considerable wasted effort can go into multiplying and adding zero derivatives, especially with higher-order derivatives. To optimize away the zeros, wrap Maybe around the derivative in D.
f2 :: R2 → R f2 (x , y) = 2 ∗ x ∗ y + 3 ∗ x + 5 ∗ y + 7 Again, let’s consider some units, to get a guess of what kind of thing d f2 (x , y) really is. Suppose that f2 measures altitude of terrain above a plane, as a function of the position in the plane. You can guess that d f (x , y) is going to have something to do with how fast the altitude is changing, i.e. the slope, at (x , y). But there is no single slope. Instead, there’s a slope for every possible compass direction (a hiker’s degrees of freedom).
data D a = D a (Maybe (D a)) The implementation can stay very simple and readable, as shown in (Elliott 2009a).
196
Now consider the conventional answer to what is d f2 (x , y). Since the domain of f2 is R2 , it has two partial derivatives:
Now what about the different chain rules, saying to combine derivative values via various kinds of products (scalar/scalar, scalar/vector, vector/vector dot, matrix/vector)? Each of these products implements the same abstract notion, which is composition of linear maps.
d f2 (x , y) = (2 ∗ y + 3, 2 ∗ x + 5) In our example, these two pieces of information correspond to two of the possible slopes. The first is the slope if heading directly east, and the second if directly north (increasing x and increasing y, respectively). What good does it do our hiker to be told just two of the infinitude of possible slopes at a point? The answer is perhaps magical: for well-behaved terrains, these two pieces of information suffice to calculate all (infinitely many) slopes, with just a bit of math. Every direction can be described as partly east and partly north (negatively for westish and southish directions). Given a direction angle ϑ (where east is zero and north is 90 degrees), the east and north components are cos ϑ and sin ϑ, respectively. When heading in the direction ϑ, the slope will be a weighted sum of the north-going slope and the east-going slope, where the weights are these north and south components. Instead of angles, our hiker may prefer thinking directly about the north and east components of a tiny step from the position (x , y). If the step is small enough and lands dx to the east and dy to the north, then the change in altitude, f2 (x +dx , y +dy)−f2 (x , y) is very nearly equal to (2 ∗ y + 3) ∗ dx + (2 ∗ x + 5) ∗ dy. If we use () to mean dot (inner) product, then this change in altitude is d f2 (x , y) (dx , dy). From this second example, we can see that the derivative value is not a range value, but also not a rate-of-change of range values. It’s a pair of such rates plus the know-how to use those rates to determine output changes. 8.3
9.
Linear maps (transformations) lie at the heart of generalized differentiation. Talking about linearity requires a few simple operations, which are encapsulated in the the abstract interface known from math as a vector space. Vector spaces specialize the more general notion of a group which as an associative and commutative binary operator, an identity, and inverses. For convenience, we’ll specialize to an additive group which provides addition-friendly names: class AdditiveGroup v where 0 :: v (+) :: v → v → v negate :: v → v Next, given a field s, a vector space over s adds a scaling operation: class AdditiveGroup v ⇒ Vector s v where (·) :: s → v → v Instances include Float, Double, and Complex , as well as tuples of v