Skip to main content
  1. Teaching/
  2. teaching/
  3. teaching/

Table of Contents

Project 2: Memory Management
#

The skeleton tarball for this project can be found here.

out of 100 points, 110 points hard cap

  1. 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 MemoryCells.

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 vectors of MemoryCells (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 MemoryCells 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 MemoryCells. 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 MemoryCells. 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 and IFGT: 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.



  1. (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. ↩︎

  2. (b0,…,b5) are also consecutive addresses. ↩︎

  3. 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. ↩︎

  4. 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. ↩︎