Project 2: Memory Management #
The skeleton tarball for this project can be found here.
out of 100 points, 110 points hard cap
- TOC {:toc}
Description #
We’ve talked about memory management in C++
and how it
differs from a language like Java. Although manual memory management is
efficient, it is often error-prone.
Garbage collection is the alternative to manual memory management: the programmer writes the code without worrying about how memory is managed. Whenever they need more memory, the program’s runtime gives it to them. Then, when certain conditions are met (usually based on time taken or space used), the program’s runtime will reclaim unused memory.
In this project, we will implement a simple garbage-collected programming language. The language is stack-based (meaning it explicitly supports the idea of doing computation on a stack), and supports two data types: integers and pairs.
Directions and Hints #
Download the the assignment file project2.tar.gz
to the lab
computers (physically or using scp
). Unpack the tarball in a new
directory:
> tar -xvf project2.tar.gz
Take a look at the skeleton code for the various classes. You will be
modifying the VirtualMachine
class and the Heap
class. As usual, most of the
high-level details are in the header files, while implementation hints are in
the .cpp
files. As usual, you may implement additional methods, members, and
functions, but do not change any of the existing function signatures.
Finally, a warning: while the garbage collection algorithm is short, it will be the hardest part to implement. Unless you have very good discipline and code sanitation, you will probably end up spending at least 50% of the total time on it (and possibly as high as 80%, if you get unlucky). Do not make the mistake of implementing everything but the garbage collector and thinking that you’re “almost done.”
Class Structure #
The class interactions in this project are pretty simple. There are four classes:
MemoryCell
Stack
Heap
VirtualMachine
The main class is the VirtualMachine. It has a Heap
and a Stack
, both of which
are simply made of MemoryCell
s.
Memory Representation #
(Note: read through the header files before reading this section, or it probably won’t make sense).
The fundamental data storage unit in our VirtualMachine
is a MemoryCell
. The
Heap
and Stack
are wrappers around vector
s of MemoryCell
s (albeit
with some constraints).
Our Stack
is where most of our operations are done: many instructions explicitly
refer to manipulating objects on the stack (e.g. “add the two objects on the top
of the stack”). However, we do not actually store objects on the stack, since
keeping the stack small is necessary for fast operations. Instead, the Stack
simply stores pointers into the Heap
.
The Heap
stores the actual data belonging to the objects. Because of
restrictions with how memory is used, we need to use more than one MemoryCell
to store each object. We refer to the entire set of MemoryCell
s needed to
store an object as the “object”. The address of an object is the address of its
first MemoryCell
.
Layout of an integer object #
An integer object is composed of two consecutive MemoryCell
s. The first
cell is a Tag
cell, which holds the tag Int
. The second cell is a Value
cell, which holds an integer.
Layout of an int object
+-----------------+------------------+
| CellType: Tag | CellType: Value |
| CellTag: Int | <data> |
+-----------------+------------------+
^
Address of the int object is address of its first (i.e. tag) cell
Layout of a pair object #
A pair object is composed of three consecutive MemoryCell
s. The first cell
is a Tag
cell, which holds the tag Pair
. The second and third cells are
Pointer
cells, which hold pointers to other Heap
data (we call those
components the “first” and “second” components of the pair respectively, even
though they are in the second and third cells).
Layout of a pair object
+-----------------+-------------------+-------------------+
| CellType: Tag | CellType: Pointer | CellType: Pointer |
| CellTag: Pair | <pointer> | <pointer> |
+-----------------+-------------------+-------------------+
^
Address of the pair object is the address of its first (i.e. tag) cell
Example Heap Layout #
Here is the memory layout of a heap that contains a pair object and two int objects 1. The two integer objects have values 10 and 11 and the pair object points to both of these integers.
{ a0 -> PAIR_TAG; a1 -> a3; a2 -> a5;
a3 -> INT_TAG; a4 -> 10;
a5 -> INT_TAG; a6 -> 11 }
Note that the MemoryCell
class comes fully implemented for you–you should not
need to make any additions to this class (though you are welcome to if you have
a good reason for it).
Instruction Set #
The virtual machine supports nine instructions, each of which is
implemented by a corresponding member function in the VirtualMachine
class. You’ll find descriptions of these functions in the comments in the
VirtualMachine.h
file, and additional implementation hints in the
VirtualMachine.cpp
file. Like last time, you’ll need to add some functionality
of your own (this time to the Stack
and Heap
classes) to make things work
smoothly.
Functions that need to be implemented are tagged with
//TODO: Implement this function
.
We will program our virtual machine using the instructions. Here is an example2:
// Creates the vm with a heap that can hold 9 cells
VirtualMachine vm(9);
// stack: empty, heap: empty
vm.pushInt(1);
// stack: < a0 >, heap: { a0 -> INT_TAG; a1 -> 1 }
vm.pushInt(2);
// stack: < a0, a2 >
// heap: { a0 -> INT_TAG; a1 -> 1; a2 -> INT_TAG; a3 -> 2 }
vm.pushPair();
// stack: < a4 >
// heap: { a0 -> INT_TAG; a1 -> 1; a2 -> INT_TAG; a3 -> 2
// a4 -> PAIR_TAG; a5 -> a0; a6 -> a2 }
vm.dup();
// stack: < a4, a4 >
// heap: { a0 -> INT_TAG; a1 -> 1; a2 -> INT_TAG; a3 -> 2
// a4 -> PAIR_TAG; a5 -> a0; a6 -> a2 }
vm.storeFirst();
// stack: < a4 >
// heap: { a0 -> INT_TAG; a1 -> 1; a2 -> INT_TAG; a3 -> 2
// a4 -> PAIR_TAG; a5 -> a4; a6 -> a2 }
vm.loadSecond();
// stack: < a2 >
// heap: { a0 -> INT_TAG; a1 -> 1; a2 -> INT_TAG; a3 -> 2
// a4 -> PAIR_TAG; a5 -> a4; a6 -> a2 }
vm.pushInt(3);
// stack: < a2, a7 >
// heap: { a0 -> INT_TAG; a1 -> 1; a2 -> INT_TAG; a3 -> 2
// a4 -> PAIR_TAG; a5 -> a4; a6 -> a2;
// a7 -> INT_TAG; a8 -> 3 }
vm.add();
// We ran out of heap memory and cannot allocate a new integer object
// However, some of the heap objects can be reclaimed
// The garbage collector runs and the memory looks like this now:
// stack: < b0, b2 >
// heap: { b0 -> INT_TAG; b1 -> 2; b2 -> INT_TAG; b3 -> 3 }
// We can now proceed to perform the addition:
// stack: < b4 >
// heap: { b0 -> INT_TAG; b1 -> 2; b2 -> INT_TAG; b3 -> 3
// b4 -> INT_TAG; b5 -> 5 }
vm.pop();
// stack: empty
// heap: { b0 -> INT_TAG; b1 -> 2; b2 -> INT_TAG; b3 -> 3
// b4 -> INT_TAG; b5 -> 5 }
One of your tasks is to implement the nine member functions of
VirtualMachine
class. To facilitate that, you will also want to add to
the Stack
class and the Heap
class. The <<
operator has been
overloaded for all three classes, so that you can directly print them
out with std::cout
or std::cerr
to facilitate debugging.
Once you finish writing these functions, you will implement the garbage
collector. If your implementation of VirtualMachine
class is correct,
then setting the heap capacity to be large enough should always execute
correctly. However, if we shrink the heap to a certain extent, it may
run out of memory cells due unclaimed garbage. You may choose to add a
new GarbageCollector
class that handles all garbage collection, or you
can integrate garbage collection directly into the Heap
class. Read
section 2.5 for more information on how the collection algorithm
can be implemented.
Garbage Collection Algorithm #
We will write what is called a stop-the-world, single-generation, compacting collector. If you are interested in other types of garbage collectors, you can search online, or check out one of UT’s many excellent books on the subject.
The heap is just a vector of MemoryCell
objects. Any allocation is
done by assigning the next two or three unused memory cells to a newly
created object, if there are enough free cells left on the heap.
Of all the nine instructions supported by the virtual machine, only
three of them try to heap-allocate new object: pushInt(n)
,
pushPair()
, and add()
. The virtual machine needs to check whether
there are enough cells on the heap for the new objects. If not, the
garbage collection procedure should be triggered. If we are still short
of memory cells, then an OutOfMemoryException
should be thrown.
When the garbage collector starts, it should scan the entire stack and get the set of objects that are pointed to from the stack, which we call the root set. The collector finds all objects that are transitively reachable from the stack:
-
For a reachable integer object, no other objects are reachable from it.
-
For a reachable pair object, both the objects pointed to are reachable from it.
After the set of reachable cells is discovered, we move and compact them consecutively on the heap. The easiest way to do this is to allocate a new heap, copy the reachable cells from the old heap to the new heap, and discard the old heap3.
You should be careful about how you implement the copy procedure: Make sure that objects on the new heap can only reference other objects on the new heap. For example, suppose we start with the following heap:
{ a0 -> INT_TAG; a1 -> 1;
a2 -> PAIR_TAG; a3 -> a0; a4 -> a2 }
After moving, the new heap should look like this:
{ b0 -> INT_TAG; b1 -> 1;
b2 -> PAIR_TAG; b3 -> b0; b4 -> b2 }
In particular, cell b_3
and b_4
should reference b_0
and
b_2
respectively, not a_0
and a_2
.
Finally, there is a locality restriction on objects: if object A has a lower address than object B before compaction, and both survive garbage collection, object A should still have a lower address than object B afterwards.
Another way of saying this is that garbage collection should look like the unreachable objects were deleted and all remaining objects were moved to the front of the container. You should not reorder objects during the gc phase.
Submission and Grading #
Make sure to read the general project grading rules.
You will submit a single tarball called project2.tar.gz
which contains a
folder inside called project2
. I must be able to do the following in an
automated manner:
tar -xf project1.tar.gz
cd project1
make clean
make
./gc
<test input>
I will be substituting my own grading stub over your code, so it is
extra important that you do not modify any of the functions in the skeleton
code, and that your code does not rely on anything in main.cpp
. In addition,
YOUR PROJECT MUST NOT GENERATE ANY OUTPUT WHEN RUNNING TESTS. I will
be placing a set of custom printing functions over your code, so if your code
generates any output whatsoever, it may be marked incorrect.
Do not implement any of the printGrading
functions or change their header
entries, or your project will break the autograder.
If you have any questions about the assignment, please ask on Piazza.
Stretch Objectives #
If you finish the project early and have time to burn, you might consider trying one of these stretch goals. They will give you small amounts of bonus points, capped at 10 points.
Fair warning: these stretch goals are not easy, and are more designed so that those of you who want to learn a little more about the weird fringes of C++ can cut your teeth. You will get many more points from implementing the main project well and testing it thoroughly than you will get from these bonuses.
If you attempt these, you must make sure your code still compiles on the lab machines and does not interfere with the correctness of the results. For this project, you will have to add new files and new rules to the Makefile. Make sure you document them in your README!
Stretch A: Making a REPL (2pts) #
Having to manually type C++ code into main
and then compile the whole thing
kinda sucks. We’d like to be able to type things in interactively, or maybe even
run scripts from the command line.
For this stretch, you will create a read-evaluate-print loop (or a REPL) for
our programming language. You may decide what syntax you want to use: for example,
the function call pushInt(4)
can be written as pushInt(4)
, or as PUSHINT 4
,
or as ⬇️ 4
. Whatever you choose, document the syntax in your README. 4
The REPL is not required to print meaningful error messages for invalid commands, but it should not crash or segfault if it’s given an invalid command. It should also gracefully handle an out-of-memory condition or a type error.
You should also add the following commands to the language:
IFZ
: Executes the following command if the top of the stack is zero, e.g.IFZ PUSHINT 3
should push 3 if the top of stack is zero.IFLT
andIFGT
: execute the command if the top of the stack is {less, greater} than the second element of the stack, respectively.PRINTVM
: Print the current state of the VM for the user to inspect.
These commands should throw the appropriate exceptions if they are impossible to execute.
Note: this stretch is actually surprisingly simple once you understand how, and links nicely into Stretch B. This is why the point value is relatively low.
Stretch B: Transpilation (yes, that’s a word) (3 pts) #
A transpiler (more
commonly known as a source-to-source compiler) takes in a source file and creates
a source file in a different language which executes the same program. For this
stretch, you will create a transpiler which goes from our stack-based language
to C++
.
Just like in Stretch A, you will be free to choose the syntax of your input, and you should add a command which prints out the VM. You should document your choice of syntax in your README.
The output of your transpiler should be a C++
source file which contains a
main
function. When compiled against your other project functions, this should
create a program that runs the computation specified.
For example (making up a command syntax), the following input:
HEAPSIZE 10
PUSH 2
PUSH 3
ADD
PRINTVM
should result in the following C++ file:
#include<VirtualMachine.h>
#include<iostream>
int main(){
VirtualMachine vm(10);
vm.pushInt(2);
vm.pushInt(3);
vm.add();
std::cout << vm << std::endl;
}
Document how to run your transpiler in your README–you should give me an easy
way of testing if your output C++ file works (e.g. by adding a rule in your
Makefile
, or writing down a command I can copy-paste to compile the output).
Up to 4 additional bonus points may be available for students who implement compiler optimizations for this stretch–talk to me if you are interested so we can work out exact point values.
Stretch C: A different kind of GC (up to 7 pts) #
A garbage collector generally has three components:
- How unreachable objects are detected (we use tracing)
- How memory is handed out when needed (we implicitly use a technique known as “bump allocation”)
- How garbage is removed from the system (we use “semi-space”)
We chose a set of configurations that are fairly similar to what’s used in the Java language, but there are many other techniques in use out there:
- Haskell uses an extension of semispace known as multigenerational collection. Its collector has incredible performance, garbage collecting gigabytes of data per second.
- Python uses reference counting with free-list allocation.
- Lisps (like Scheme and Racket) tend to use a mark-and-sweep scheme–one of the classic techniques for garbage collectors.
- Much more advanced techniques (such as incremental gc, which does not have to stop the world) can also be used.
Pick a different GC system and implement it. Some popular choices you might look into are:
Note that refcounting has problems dealing with cycles, which we are allowed to have in our language, e.g. via a pair object pointing to itself. You will need to work out a way around that.
Document what algorithm you chose to implement: how you detect garbage, how you free garbage, and how you allocate memory to new objects. As long as you don’t choose to implement a trivial delta and your implementation doesn’t crash all the time, you will get 5 points. Two extra points are available for particular impressive implementations.
-
(a0,…,a6) are consecutive addresses of memory cells. Note that because of ASLR, the exact numbers will be different every time. However, they should always be consecutive, and in the same positions relative to each other. ↩︎
-
(b0,…,b5) are also consecutive addresses. ↩︎
-
Of course, this is a simplification of the compaction problem. Real-world machines cannot create a new heap. Instead they partition the heap into two ‘semi-spaces’, and reserve one semi-space for the purpose of move-and-compact only. ↩︎
-
Note that if you choose to use non-ASCII characters, you’ll need to learn how to use alternate string types like wchar_t…and this gets messy very, very quickly. You will also need to tell me exactly which unicode codepoints you are using, and provide an example character in your README for me to copy. ↩︎