COMP40 Assignment: A Universal Virtual Machine

 

COMP40 Assignment: A Universal Virtual Machine

Design due Thursday, November 3 at 11:59 PM.
Implementation and unit tests due Sunday, November 13 at 11:59 PM.

Table of Contents


Purpose and overview

The purpose of this assignment is to understand virtual-machine code (and by extension machine code) by writing a software implementation of a simple virtual machine.

Specification of the Universal Machine

Machine State

The UM has these components:

One distinguished segment is referred to by the 32-bit identifier 0 and stores the program. This segment is called the '0' segment.

Notation

To describe the locations on the machine, we use the following notation:

Initial state

The UM is initialized by providing it with a program, which is a sequence of 32-bit words. Initially

Execution cycle

At each time step, an instruction is retrieved from the word in the 0 segment whose address is the program counter. The program counter is advanced to the next word, if any, and the instruction is then executed.

Instructions' coding and semantics

The Universal Machine recognizes 14 instructions. The instruction is coded by the four most significant bits of the instruction word. These bits are called the opcode.



Some instructions ignore one or more of the register numbers A, B, and C.|width 0pt depth 6pt
NumberOperatorAction
 0Conditional Moveif r[C] !=0 then r[A] :=r[B]
 1Segmented Loadr[A] :=word(m[r[B]], r[C])
 2Segmented Storeword(m[r[A]], r[B]) :=r[C]
 3Additionr[A] :=(r[B] + r[C]) mod2^32
 4Multiplicationr[A] :=(r[B] ×r[C]) mod2^32
 5Divisionr[A] :=|_r[B]  / r[C]_|
 6Bitwise NANDr[A] :=~(r[B]  & r[C])
 7HaltComputation stops.
 8Map SegmentA new segment is created with a number of words equal to the value in r[C]. Each word in the new segment is initialized to 0. A bit pattern that is not all zeroes and that does not identify any currently mapped segment is placed in r[B]. The new segment is mapped as m[r[B]].
 9Unmap SegmentThe segment m[r[C]] is unmapped. Future Map Segment instructions may reuse the identifier r[C].
10OutputThe value in r[C] is displayed on the I/O device immediately. Only values from 0 to 255 are allowed.
11InputThe universal machine waits for input on the I/O device. When input arrives, r[C] is loaded with the input, which must be a value from 0 to 255. If the end of input has been signaled, then r[C] is loaded with a full 32-bit word in which every bit is 1.
12Load ProgramSegment m[r[B]] is duplicated, and the duplicate replaces m[0], which is abandoned. The program counter is set to point to word(m[0], r[C]). If r[B] = 0, the load-program operation is expected to be extremely quick.
13Load ValueSee semantics for ``other instruction'' in Section [->].

Semantics of UM instructions [*]


Three-register instructions

Most instructions operate on three registers. The registers are identified by number; we'll call the numbers A, B, and C. Each number coded as a three-bit unsigned integer embedded in the instruction word. The register C is coded by the three least significant bits, the register B by the three next more significant than those, and the register A by the three next more significant than those:

                                      A     C
                                      |     |
                                      vvv   vvv                    
              .--------------------------------.
              |VUTSRQPONMLKJIHGFEDCBA9876543210|
              `--------------------------------'
               ^^^^                      ^^^
               |                         |
               opcode                    B
Semantics are given in Figure [<-].

One other instruction

[*]

One special intruction, with opcode 13, does not describe registers in the same way as the others Instead, the three bits immediately less significant than opcode describe a single register A. The remaining 25 bits indicate a value, which is loaded into r[A].

                   A  
                   |  
                   vvv
              .--------------------------------.
              |VUTSRQPONMLKJIHGFEDCBA9876543210|
              `--------------------------------'
               ^^^^   ^^^^^^^^^^^^^^^^^^^^^^^^^
               |      |
               |      value
               |
               opcode == 13

Failure modes

[*] The behavior of the Universal Machine is not fully defined; under circumstances detailed below (and only these circumstances), the machine may fail.

In the interests of performance, failure may be treated as an unchecked run-time error. Even a core dump is OK. Go wild!

Resource exhaustion

If a UM program demands resources that your implementation is not able to provide, and if the demand does not constitute failure as defined in Section [<-], your only recourse is to halt execution with a checked run-time error.

Advice on the implementation

This problem presents two challenges:

There are also some pitfalls: And finally there are some useful things to know:

Emulating a 32-bit machine: Simulating 32-bit segment identifiers

On a 32-bit machine, you could simply use a 32-bit pointer as a segment identifier and have malloc do your heavy lifting. On the 64-bit machine, you will need an abstraction that maps 32-bit segment identifiers to actual sequences of words in memory. (Any representation of segments I can think of requires at least 64 bits to store.) Hanson's CII library is there if you need it.

Plan to reuse 32-bit identifiers that have been unmapped. One way is to store them in one of Hanson's sequences (Seq_T). A wonderful C99 trick is that you can cast an uintptr_t to a void *, so statements like

  Seq_addlo(ids, (void *)(uintptr_t)id);
  return (Umsegment_Id)(uintptr_t)Seq_remlo(ids);
might be useful. (I have written typedef uint32_t Umsegment_Id.)

Efficient abstractions

Your choice of abstractions can easily affect performance of your UM by a factor of 1000. We will provide a benchmark that a well-optimized UM should be able to complete in about 1 second; a UM designed without regard to performance might take 20 minutes on the same benchmark. To get decent performance, focus on two decisions:

In some cases you can achieve the benefits of procedural abstraction and type checking without any run-time overhead by writing static inline procedures. If such procedures are reusable, it can be appropriate to put them in a .h file.

Controlling use of CPU and memory

When we test your UM, we will give it only 1000 MB of memory, and we will limit its CPU time as well. Because you can easily overlook how much time and space your UM needs, we provide the commands mem-limited and cpu-limited, which ensure that your UM runs within specified limits. For example, to run with 500 MB of memory for at most 10 seconds, you can run

  mem-limited 500m cpu-limited 10 ./um midmark.um
If your UM exceeds its limits, these programs will halt it. For more information about forced halts or other failures, run
  catch-signal mem-limited 500m cpu-limited 10 ./um midmark.um
These commands are not documented, but they are available when you run use comp40.

Avoid common mistakes

Following this advice will help you avoid common mistakes:

What we expect of you

Your design and its documentation

The documentation of your design should include

Remember we don't want your complete design checklist—show us only the interesting parts.

In this assignment we are raising the bar for your design work:

Universal Machine unit tests

Your design submission should include unit tests for the Universal Machine segment abstraction, but not for the Universal Machine instruction set. Your final submission should also include unit tests for the instruction set, represented as described below. In your README file, include documentation of a sequence of unit tests that in toto cover all of the Universal Machine instructions. Each unit test may rely on correct functioning of instructions from previous unit tests. For example:

And so on.

Every unit test must include a compiled UM binary whose name ends in .um. [You may not use the names \texttt{midmark.um} or \texttt{selfcheck.um}.] You will write code to create these binaries. No unit test may cause any of the failures listed in Section [<-].

Unit tests may also include additional files. If your UM binary is called hello.um, your unit test may include these additional files:

Each of your unit tests will be evaluated as follows:
  1. We will run the test using a correct Universal Machine, using the input you provide. For example, we will run
      good-um hello.um < hello.0
    
    or if you do not provide a hello.0,
      good-um hello.um < /dev/null
    
    We will expect this command to produce output identical to hello.1, or if you do not provide a hello.1, we will expect it to produce no output.
  2. If your test produces unexpected output, or if it causes the reference machine to fail, your test is invalid. Invalid tests lower your grade and play no further role in the process.
  3. If your test produces the expected output with no failures, it is valid. Each valid test is run against all the UMs submitted by all the other pairs working on the problem. It is also run against a selection of faulty UMs that we create.
The more faulty UMs your tests detect, the higher your grade.

[*]

Implementation

We expect you to write a complete and correct implementation of the Universal Machine. Moreover, we expect it to be efficient enough to execute a UM benchmark of 50 million instructions in less than 100 CPU seconds on the lab machines; that's half a million instructions per second.

The UM is a virtual machine. One of the purposes of virtualization is to insulate the real (``host'') hardware from bad behavior by client (``guest'') software. For example, in the Amazon Elastic Compute Cloud, no matter how badly the client binaries behave, Amazon makes sure that when a virtual server halts, all machine resources are recovered. (Any other strategy would leave Amazon with machine resources that aren't earning any revenue.) Similarly, no matter how badly a UM client behaves, your implementation must ensure that, when the UM finishes running, all available machine resources are recovered.

For testing, you will find it useful to implement the UM as a library. However, we will be evaluating a command-line version which is a command-line program that expects exactly one argument: the name of a file containing a UM program. When a UM program is stored in a file, words are stored using big-endian byte order.

The UM ``I/O device'' should be implemented using standard input and standard output.

What to submit

Design

Using the script submit40-um-design, please submit

Implementation

Using the script submit40-um, please submit

On a 32-bit machine, most experienced C programmers can understand the Universal Machine specification and build an implementation in a total of two hours. We expect you to take about two hours to analyze the assignment, four hours to prepare your design and unit tests, and four hours to build a working implementation.

My implementation is about 200 lines of C code; almost half is devoted to conversions between 64-bit pointers and 32-bit Universal machine identifiers. Reading arguments and loading the initial program takes about 35 lines, so the Universal Machine itself is well under 100 lines of code.

What we provide for you

We provide the following useful items: