Assignment 5: Diamondback: Defining functions
Due: Tue 02/20 at 8:59pm
git clone
In this assignment, you’ll implement a compiler for a small language with functions declarations and function calls, that can communicate with functions written in C. You’ll also add some static well-formedness checks to the compiler.
As for the project acronym, so far our greatest minds have come up with
Designing an
Intel Architecture
MOstly dynamic
Nested-expression
(Diamondback supports recursion)
Boolean-tagged
ANF-transformed
Compiler.
...K?
1 The Diamondback Language
As usual, we have concrete and abstract syntaxes, along with a specification of semantics.
1.1 Concrete Syntax
The major addition to Diamondback are function declarations. Our programs are now a sequence of zero or more function declarations, followed by a single main expression.
‹program› ‹decls› ‹expr› ‹expr› ‹binop-expr› IDENTIFIER NUMBER true false ‹prim1› ( ‹expr› ) ‹expr› ‹prim2› ‹expr› IDENTIFIER ( ‹exprs› ) IDENTIFIER ( ) true false ( ‹expr› ) ‹prim1› add1sub1 ! printprintStack isboolisnum ‹prim2› +-* <><=>= == &&|| ‹decls› ‹decl› ‹decl› ‹decls› ‹decl› def IDENTIFIER ( ‹ids› ) : ‹expr› def IDENTIFIER ( ) : ‹expr› ‹ids› IDENTIFIER IDENTIFIER , ‹ids› ‹expr› let ‹bindings› in ‹expr› if ‹expr› : ‹expr› else: ‹expr› ‹binop-expr› ‹exprs› ‹expr› ‹expr› , ‹exprs› ‹bindings› IDENTIFIER = ‹expr› IDENTIFIER = ‹expr› , ‹bindings›
Diamondback also supports line comments, beginning with a #
and
continuing to the end of the line.
The other addition is function applications, which are written
IDENTIFIER(‹exprs›)
, for
example f(1, 2, 3)
. This is the syntax for a call to a function.
Note that primops and function calls are whitespace-sensitive: there can be no
space before the left parenthesis. (This is a workaround for a grammatical
ambiguity in the language. You can see a remnant of that ambiguity by the "1
shift/reduce conflict" warning produced by ocamlyacc
in the provided
code.)
1.2 Abstract Syntax
In this assignment, we distinguish between the surface syntax that the parser
produces (expr
) from the ANF expression forms (aexpr
). All these
types are given in exprs.ml
; below we show a simplified, undecorated form:
type prim1 =
| Add1
| Sub1
| Not
| Print
| IsNum
| IsBool
type prim2 =
| Plus
| Minus
| Times
| And
| Or
| Greater
| GreaterEq
| Less
| LessEq
| Eq
(* Surface expressions *)
type bind = (string * expr)
type expr =
| ELet of bind list * expr
| EPrim1 of prim1 * expr
| EPrim2 of prim2 * expr * expr
| EApp of string * expr list
| EIf of expr * expr * expr
| ENumber of int
| EBool of bool
| EId of string
type decl =
| DFun of string * string list * expr
type program =
| Program of decl list * expr
(* Internal ANF expressions *)
type immexpr =
| ImmNumber of int
| ImmBool of bool
| ImmId of string
and cexpr =
| CPrim1 of prim1 * immexpr
| CPrim2 of prim2 * immexpr * immexpr
| CApp of string * immexpr list
| CIf of immexpr * aexpr * aexpr
| CImmExpr of immexpr
and aexpr =
| ALet of string * cexpr * aexpr
| ACExpr of cexpr
and adecl =
| ADFun of string * string list * aexpr
and aprogram =
| AProgram of adecl list * aexpr
The EApp
and CApp
constructors correspond to function applications, and
the decl
and adecl
types respresent function declarations. A program is a
list of decls
(surface) or adecls
(ANF), with a main expression.
1.3 Semantics
There are several distinguishing features of Diamondback. The first is function applications. A function application should give the answer we’d get if we followed the rules for substituting argument values for parameter names. So, for example:
def f(x, y):
x + y
f(1, 2)
should produce 3.
Logical operators &&
and ||
should
short-circuit: they should have the same behavior as you gave them in Assignment 4.
Since our Diamondback functions only call other Diamondback functions, rather
than arbitrary functions in C, you can use a simpler calling convention than the
full x64 calling convention: you can simply push all the arguments onto the
stack and pop them all back off again. For primitives like
print
, obviously, you’ll need to follow the proper
x64
stack layout. (If you’d like to use the x64 calling convention everywhere,
you certainly may do so. Test thoroughly.)
There are a number of new errors that can occur now that we have function declarations and calls. Your implementation should catch all of these cases statically; that is, at compile time before the program runs:
A function application with the wrong number of arguments should signal an
Arity
errorA function application of a non-existent function should signal an
UnboundFun
errorAn identifier without a corresponding binding location should report an
UnboundId
errorA let binding with duplicate names should report a
DuplicateId
errorA function declaration with duplicate names in the argument list should report a
DuplicateId
errorIf there are multiple function definitions with the same name, report a
DuplicateFun
errorIf a numeric constant is too large, report an
Overflow
error
Again, these errors should stop the program from compiling, not happen
at runtime. Moreover, you should report all the errors in this
program, not just the first one encountered. See the notes on is_well_formed
below for implementation details.
These errors, in addition to ParseError
s, are all defined in
errors.ml
. Additionally, there are two more errors given to you:
NotYetImplemented
errors, which you should obviously remove and implement;
and InternalCompilerError
s, which indicate intended-to-be-impossible
scenarios in your compiler. Ideally, your final compiler should not need these
(either by coming up with useful semantics in all scenarios, or by using the
ML type system to your advantage to avoid any such unplanned scenarios), but in
practice you might still need them.
1.4 Implementation
The file assembly.ml
gives you all the assembly instructions you should
need, though you’re free to add your own new instructions if you want.
There are a few new pieces to the implementation:
A new structure for ANF
A new
is_well_formed
functionA new
AProgram
structureA new
locate_bindings
phase
Each of these contains an implementation task; you should complete these four before moving on to tail-calls, below.
1.4.1 A new structure for ANF
You should study and understand it (see
Lecture 8: Revisiting ANF: Encoding A-Normal Form with Types) so you can implement the EApp
case. This is the first
thing you should add.
1.4.2 A new is_well_formed
function
This function is called prior
to performing ANF and are used to report the errors above. This function
either returns the program unchanged, or returns an exn list
, which
represents all the errors that are found in a program or expression. So
a program like
def f(x, x):
y
f(1)
would report three errors, one for y
being unbound, one for
duplicated x
parameters, and one for the arity mismatch on the
call to f
.
These errors can be reported in any order; in general (and in grading), it’s
easy to test for one at a time. But reporting multiple errors makes using the
main
of the compiler much more pleasant, and is a nice view into the
kinds of compiler ergonomics we should expect from a modern compiler.
1.4.3 The AProgram
structure
This contains both function declarations and the expression for
our_code_starts_here
. You will probably want to generate an assembly
structure that looks like:
;; extern and global stuff
section .text
...
fun_decl1:
;; code for fun_decl1, including stack management
fun_decl2:
;; code for fun_decl2, including stack management
...
our_code_starts_here:
;; main entrypoint, as before, with stack management
;; errors, as before
internal_error_non_number:
...
(Hint: while the AProgram
construct is not quite uniform, since the body
is treated separately from the function definitions, a relatively
simple observation makes generating the output assembly of the entire program
very uniform...)
1.4.4 locate_bindings
In Cobra, in compile_expr
, you used a stack index parameter, si
, that
accumulated how many let-bindings were currently in use on the stack and built
up an environment mapping each let binding to a stack index, which you could
then use to compile EId
variable usages by looking up their names in that
environment and turning the stack indices into an appropriate location
RegOffset(~-8 * si, RBP)
. Since we’re also splitting compile_expr
into several functions over the ANF’ed AST, we should take the opportunity to
refactor this logic into its own phase, to keep it simpler and self-contained.
You should define a new function, naive_stack_allocation
, that implements
the stack-index manipulations and returns an environment mapping every
let-bound name to its appropriate RegOffset
; then the EId
case
simplifies to looking up the name in that environment and using the resulting
RegOffset
directly. Note that in Cobra, you only used the si
parameter to allocate stack indices for let-bound variables and not function
parameters; your naive_stack_allocation
function should do the same. It
might seem odd to have an environment containing all of the names in
your program, all at once, but because we’ve already performed check_scope
to ensure names are only used correctly in scope, and because we’ve already
performed rename
to ensure every let-binding has a unique name, this
whole-program environment is safe to use.
In order for this function to compose into your pipeline and be useful by
compile_prog
after it, it will actually need to return a pair of the
ANF’ed program and the environment you just computed.
Once you’ve implemented this function, you can replace your use of
count_vars
from Cobra with a call to deepest_stack
, which will take
an aexpr
and the environment, and determine the number of words your stack
frame needs by finding the deepest stack offset used by any of the local
variables in your expression. If you’ve implemented
naive_stack_allocation
correctly, then count_vars
and
deepest_stack
will always produce the same answers.
1.5 Tail calls
Refine your compilation of function applications to support tail-calls. There are three levels of complexity here:
6410: required; 4410: optional. Support self-recursive tail-calls.
6410: required; 4410: optional. Support arbitrary tail-calls, when the number of arguments is the same or decreasing between caller and callee.
Everyone: Optional. Support arbitrary tail-calls, when the number of arguments is different between caller and callee.
To support the last one, you’ll need to be creative with the calling
convention. The only truly essential feature of a stack frame is knowing where
the saved RSP
, RBP
and return addresses must be, and all other
variables’ locations can be calculated from knowing those. In particular,
you’ll need to change the ordering of values in your stack frame, where
RBP
points between parameters and locals. Instead, both parameters and
locals will need to be above RBP
: change your compilation
of function calls —call
instruction (why?), so you’ll need to come up with something clever to save
the desired return address. Come see me if you try this, for a hint.
To detect that you’ve implemented tail calls correctly, you may want to attempt the following extra credit.
1.6 Extra credit: Printing stack traces
Implement a new prim1
operator, printStack
, that takes a
single argument and returns it, just like print
does. What makes
printStack
special is its output: it will print out a stack trace
of all the call frames from wherever it’s called, down to wherever
our_code_starts_here
began. For example, given the following program:
def fact(acc, n):
if n < 1: printStack(acc)
else: fact(acc * n, n - 1) + 0
# NOTE: This + 0 is to prevent tail-recursion in this example
fact(1, 3)
RSP: 0x00007ffc352b1bb0 ==> 0
RBP: 0x00007ffc352b1bd0 ==> 70360600251912
(difference: -4)
Requested return val: 0x000000000000000c ==> 6
Num args: 2
0x00007ffc352b1bb0: 000000000000000000 ==> old rbp
0x00007ffc352b1bb8: 000000000000000000 ==> 0
0x00007ffc352b1bc0: 000000000000000000 ==> 0
0x00007ffc352b1bc8: 0xffffffffffffffff ==> true
RBP 0x00007ffc352b1bd0: 0x00007ffc352b1c10 ==> old rbp
0x00007ffc352b1bd8: 0x0000000000401077 ==> saved ret
0x00007ffc352b1be0: 0x000000000000000c ==> 6
0x00007ffc352b1be8: 000000000000000000 ==> 0
0x00007ffc352b1bf0: 000000000000000000 ==> 0
0x00007ffc352b1bf8: 000000000000000000 ==> 0
0x00007ffc352b1c00: 0x000000000000000c ==> 6
0x00007ffc352b1c08: 0x7fffffffffffffff ==> false
RBP 0x00007ffc352b1c10: 0x00007ffc352b1c50 ==> old rbp
0x00007ffc352b1c18: 0x0000000000401077 ==> saved ret
0x00007ffc352b1c20: 0x000000000000000c ==> 6
0x00007ffc352b1c28: 0x0000000000000002 ==> 1
0x00007ffc352b1c30: 000000000000000000 ==> 0
0x00007ffc352b1c38: 0x0000000000000002 ==> 1
0x00007ffc352b1c40: 0x000000000000000c ==> 6
0x00007ffc352b1c48: 0x7fffffffffffffff ==> false
RBP 0x00007ffc352b1c50: 0x00007ffc352b1c90 ==> old rbp
0x00007ffc352b1c58: 0x0000000000401077 ==> saved ret
0x00007ffc352b1c60: 0x0000000000000006 ==> 3
0x00007ffc352b1c68: 0x0000000000000004 ==> 2
0x00007ffc352b1c70: 000000000000000000 ==> 0
0x00007ffc352b1c78: 0x0000000000000004 ==> 2
0x00007ffc352b1c80: 0x0000000000000006 ==> 3
0x00007ffc352b1c88: 0x7fffffffffffffff ==> false
RBP 0x00007ffc352b1c90: 0x00007ffc352b1cb0 ==> old rbp
0x00007ffc352b1c98: 0x00000000004010c1 ==> saved ret
0x00007ffc352b1ca0: 0x0000000000000002 ==> 1
0x00007ffc352b1ca8: 0x0000000000000006 ==> 3
BOT 0x00007ffc352b1cb0: 0x00007ffc352b1cf0 ==> old rbp
0x00007ffc352b1cb8: 0x0000000000400f37 ==> saved ret
0x00007ffc352b1cc0: 0x00007f04f63949a0 ==> heap
6
The output shows:
The current values of
RSP
andRBP
at the moment of the theprintStack
call. The difference between them should be the size of the current stack frame.The requested return value is the argument given to
printStack
.Num args
shows the number of arguments passed to the current function – i.e., the two arguments given tofact
.The next bunch of segments show individual stack frames. The three columns show the memory address, the value at that address, and then the result of
print
ing that address as a Diamondback value, or identifying it as the savedRBP
and return address. Each line labelledRBP
indicates the beginning of a new stack frame: the value at each of those lines contains the address of the nextRBP
line.Looking at these stack frames, from bottom to top, you can see: the boolean result of
n < 1
, local valuesacc * n
andn - 1
, a slot for the return value of thefact(...)
call (which is still zero because the call hasn’t finished yet), then the argumentsn
andacc
for the next call.The final segment, marked
BOT
, is the stack frame ofmain
itself.The last line of output is the final output of the entire program, and comes from the
printf
inmain()
.
Hint: To identify where to stop printing the stack (after all, main
isn’t the very last thing in the stack; there are operating-system frames as
well...), I used a trick: declare a global, non-const
variable in C,
uint64_t* STACK_BOTTOM
, and mark it as extern
. At the beginning of
our_code_starts_here
, initialize that value with the current value of
RBP
(you’ll need a new assembly arg
for this), and then you’re
guaranteed to know where your stack ends, dynamically.
Note: if you’ve properly implemented tail calls, then eliminating the
+ 0
from the program above, and rerunning it, should produce a
much shorter stack trace...
Note: If you implement this extra credit, provide a brief, written
explanation (in a comment in main.c
, near whatever new functions you
define) of why this must be implemented as a EPrim1
and not as a
EApp
expression.
1.7 Testing
There is one new testing function provided, tvg
. This works just like t
,
except it runs valgrind
on the executable it creates, and checks whether or
not it reports errors. If valgrind
does report memory errors, the test
fails and the errors are reported. The test succeeds if there are no memory
errors and the answer is correct.
You can test the well-formedness errors using the error tester as usual.
2 Running main
Running your own programs is the same as with Cobra, except you’ll give them
the .diamond
file extension.
3 List of Deliverables
your
compile.ml
any additional modules you saw fit to write
any starter modules that you modifed
tests in an OUnit test module (
test.ml
)any test input programs (
input/*.diamond
files orinput/*/*
subdirectories)any additional files that are needed to build your tests (e.g.
main.c
, etc)
Again, please ensure the makefile builds your code properly. The black-box tests will give you an automatic 0 if they cannot compile your code!
DO NOT SUBMIT YOUR .git
DIRECTORY! For that matter, don’t submit
your output
or _build
directories.
4 Grading Standards
For this assignment, you will be graded on
Whether your code implements the specification (functional correctness),
the clarity and cleanliness of your code, and
the comprehensiveness of your test coverage
5 Submission
Wait! Please read the assignment again and verify that you have not forgotten anything!
Please submit your homework to https://handins.khoury.northeastern.edu/ by the above deadline.