|
[previous lecture] [next lecture] COSC48 Implementation of Programming LanguagesLecture 16aInterpreters - continuedReview
Control logicA few ops (in addition to the arithmetic functions) are needed to implement control. HALT, JUMP, TRUEJMP, and FALSEJUMP provide everything that is needed for branching. Ops PUSH, POP, CALL and RETURN would implement functions. It may be convenient to have an ENTER and LEAVE to establish local store. Conventional fetch/execute interpretationThe main loop of a conventional interpreter looks like:
for (;;) {
op = fetch(8); /* get opcode */
switch (op) {
case BINARYOP:
bop = fetch(8); /* get arith modifier */
rgt = pop();
lft = pop();
push(binary(bop, lft, rgt));
break;
case...
};
This loop compiles into about 50 instructions, 10 of which are in the loop and switch, and 40 of which are in the BINARYOP case. Even more instructions are hidden inside fetch(), binary(), push() and pop(). Since a call to binary op is needed to execute "+", the average ratio for interpretation as contrasted to compiled execution is about 50:1. Some early computers, particularly those from Burroughs, implemented similiar algorithms in the hardware. In the CISC tradition. Erv Houk, the engineer in charge of the B6500, said to me that the complexity of the B6500 was just about at the human limit. Of course, it was built in discrete logic (transistor looked like a piece of candy). I am sure that integrated circuits would have eased their mental pain. Over time the CISC manufacturers switched to RISC or dropped out. Digital stuck to the CISC VAX for a long time, and did well. Finally it produced alpha and went steadily down to a buyout by Compaq, which is heading down the same track. Only Intel stuck to CISC. The street talk is the SUN will abandon its hardware in favor of intel chips soon. Inlined fetch/execute interpretationYou can get the interpreter degradation ratio down to about 30:1 by inlining everything. The Java interpreter runs with a ratio of about 26:1.
case BINARYOP:
bop = *pc++; /* get arith modifier */
switch (bop) {
case '+':
rgt = stk[--next];
lft = stk[--next];
stk[next++] = lft+rgt;
break;
}
break;
Recursive fetch/execute interpretationOne not-so-obvious advantage to inlining everything is that all the interpreter machinery (stk, pc, ...) can be local to the interpreter. This makes the interpreter capable of recursion (or re-entrancy) which is harder to achieve when functions like pop() are called. The called functions need to get at the interpreter machinery, which forces the machinery into static global scope (everyone can get at the common state). But then recursion will trash the state of the caller. A solution using functions to organize the interpreter (good engineering) while providing a common state is to add an additional context parameter to every call needing to get at the context. Below, that is every call but binary() which is given all its inputs directly.
typedef struct {
Opd *stk;
int next;
unsigned char *pc;
} *Ctx;
Opd
pop(Ctx ctx) {
return ctx->stk[--ctx->next];
}
for (;;) {
op = fetch(ctx, 8); /* get opcode */
switch (op) {
case BINARYOP:
bop = fetch(ctx, 8); /* get arith modifier */
rgt = pop(ctx);
lft = pop(ctx);
push(ctx, binary(bop, lft, rgt));
break;
case...
};
Switchless fetch/execute interpretationOnce the additional parameter is added, it is sometimes just simpler to hide the entire operator inside a function call. This results in a stylized fetch/execute where the only purpose of the switch is to select an interpretive function.
for (;;) {
op = fetch(ctx, 8); /* get opcode */
switch (op) {
case BINARYOP:
binaryOp(ctx);
break;
case...
};
The stylized fetch/execute mechanism can then be simplified. An array of pointer-to-function provides access to the interpretive functions. They all have the same signature FN.
typedef void(*FN(Ctx));
static FN ifun[] = {
zeroaryOp,
unaryOp,
binaryOp,
...
};
for (;;) {
int op = fetch(ctx, 8); /* get opcode */
FN fn = ifun[op]; /* get interpretive function */
fn(ctx); /* call interpretive function */
};
The call to fn may implement branching logic or the implemented operator may take parameters from the code stream, in which case fn modifies pc in the common context so that fetch gets the right next instruction next time around the loop. Hard-coded fetch/execute interpretationThe pfn code is now used merely to index into an array of pointers. One could instead do away with the pcode altogether, and instead represent the program as a sequence of function calls.
jsp binaryop
jsp unaryop
jsp binaryop
...
This is not the whole story, Parameters must be passed to the routines, and branch logic needs to be inserted. But it is more portable and less work that a real compiler. Approaching compilationNow that the fetch/execute mechanism is out of the way, it is possible to inline faster code in places where it matters. Thus we might have
jsp binaryop
fchs
jsp binaryop
...
where the X86 float stack change-sign operation is used instead of a call to unaryop. Eventually this leads to unoptimized machine code (about the quality produced by mxcom). Optimization requires a more fundamental approach. [previous lecture] [next lecture] Created: Wednesday, May 16, 2001Last modified: Thu May 17 08:51 EDT 2001 |