CHAPTER 9
ASSOCIATIVE MEMORY
This chapter explores memory and its use. Pointers and data sharing
are introduced. We discover a very efficient representation for
holding enormously many phrases. Chapter 12 presents a parser based
on this representation. Chapter 15 applies this efficient
representation to hold many ~meanings.
The next chapter considers what ~modification of memory can mean.
9.1
Memory
Human memory is associative. Given a stimulus, like a certain smell,
many associations arise, like the details of a certain visit to a
relative's home. The past manifests itself in the present by turning
partial information into more complete information. The more complete
information came along with the partial information in the past, when
the association was established.
9.2
Random Access Memory
Random access memory (RAM) associates addresses and data. The
~address of a bin is associated with the ~contents of that bin. RAM
plays the role of turning addresses into data. This association
between addresses and contents is implemented by a RAM in only one of
two directions: Address can be turned into contents, but contents
can't be turned into addresses.
There are memories that turn contents back into addresses. This kind
of memory is called ~content ~addressable ~memory (CAM). The
implementation of a CAM requires more of each bin than merely the
storage of data. Each bin must also have a comparator, so that it can
compare its contents against the desired contents given to the CAM.
Thus, CAM is substantially more expensive than RAM.
Another difference between CAM and RAM is that a CAM may deliver
several addresses corresponding to the desired contents, because
several bins might contain the same contents. RAM, in contrast,
always delivers one contents when given one address.
9.3
Association By Address Proximity
A RAM certainly associates each bin's address with its contents.
However, there is another kind of association built on top of the RAM's
address/contents associations. Two adjacent bins, bins whose addresses
differ by one, are associated by proximity.
For example, we can associate a person's name, age, and sex in three
consecutive bins in memory, as shown in figure 9.1. These data are
kept associated together by their adjacent addresses.
Even if given only the address of the first bin, you can still get at
the three pieces of information. The addresses of the second and third
bins are computed by adding 1 and 2 respectively to ghe given address
of the first bin. This three-bin piece of memory is called a ~block
of memory.
9.4
Association By Pointer
Two blocks of memory can be related even if they reside far from one
another. One block can contain in one of its bins the ~address of
the other block.
Figure 9.2 shows the block at address 93176, whose second bin points to
the bin at address 12345. Figure 9.2(b) shows that all we need to do
to represent that pointer is to put the address of where the arrow
ends into the bin from where the pointer starts. Figure 9.2(c)
illustrates what a pointer means in general.
One of the remarkable things about a pointer is that it consumes only
one bin of memory. The bin from where the pointer emanates
can be arbitrarily far from where the pointer culminates. No matter
how far apart the start is from the end, RAM guarantees we can get from
the start to the end as fast as we can get from anywhere to anywhere
else. Long pointers cost no more than short pointers.
(In microchip designs, such a connection between two points on the
microchip costs much. Between the start and finish, silicon area is
quickly consumed as a wire must exist all the way from the start to the
finish. Long wires consume more area than short wires. In microchips,
wires can't cross freely lest they short together.
Pointers can cross freely (as in figure 9.3) because no substance is
required between the start and end of any pointer.
(The low cost of pointers in computer
memory support much more complex software systems than can exist on a
microchip. Of course, the microchip can execute faster than software
can because all wires can be traversed ~simultaneously, whereas in
software, only one pointer can be followed at a time).
Figure 9.3 shows another memory block. This block, labelled "family",
holds four pointers. As with any block, those four pointers are
associated together by proximity of the bins' addresses. From the
point of view of that family block, it is easy to get to any family
member (to each person block).
The person and family blocks would be good representations for the
datatypes PERSON and FAMILY as declared in ICL:
TYPE PERSON = [ NAME: TEXT
AGE: INT
SEX: BOOL ] ;
TYPE FAMILY = [ FATHER: PERSON
MOTHER: PERSON
CHILD1: PERSON
CHILD2: PERSON ] ;
BOX: How might you define FAMILY so as to have any number of
BOX: children?
9.5
Walking A Pointer In Machine Language - Indexing
Our presentation of machine language in chapter 6 omitted one
particularly useful feature. All instructions' operands were specified
as addresses, addresses that were bolted into the machine instructions.
If the address of an intruction's operand needed to be variable, e.g.,
computed, we were out of luck.
In general, the second operand, the memory operand, can be specified
with the old notation:
333
or with the augmented notation:
333(1)
Parentheses enclosing a register address can now be appended to the
otherwise constant 333. The address expression
333(1)
denotes the address computed by taking the contents of register 1 and
adding 333 to it. For example, if register 1 now contains the number
222, then
333(1)
denotes the address 555. This address expression can occur in any
instruction, such as
LOAD 2 , 333(1)
This loads into register 2 the contents of bin 555. If we were to
execute this instruction at another time, register 1 might have a
different value, and hence this one instruction could work on different
addresses at different times.
The register used in an address expression is called the ~index
~register.
For another example, let's assume that register 1 contains the address
of the block shown in figure 9.1. The following address expressions
denote the three bins shown:
0(1) is the bin containing John
1(1) is the bin containing 35
2(1) is the bin containing Male.
Remember that the register appears inside the parentheses, and the
displacement (what adds to the register's contents) appears outside
the parentheses.
Suppose we have in register 1 the address of the "family" block shown
in figure 9.3. How do we get to the father of the family? That is,
how do we get to the person block containing John,35,Male? The
following program puts into register 2 the address of that person
block:
LOAD 2 , 0(1)
The first word of the family block is loaded into register 2. Register
2 thus has the address of the person block.
Suppose again that register 1 has the address of the family block.
How do we get the mother's age? The following puts the mother's age
into register 2:
LOAD 2 , 1(1) "Make reg 2 point to mother's person block"
LOAD 2 , 1(2) "Get the age from that person block"
We've traversed two pointers. Starting in register 1 is a pointer to
the family block. The first instruction traverses that pointer and
leaves in register 2 the contents of the family block's second word.
That second word itself is a pointer. The second instruction
traverses that pointer, the pointer to a person block, and ultimately
loads into register 2 the second word in that block, the mother's age.
Suppose we want to discover the difference in age between a family's
father and mother. Again, if register 1 points to the family block,
the following program puts the age difference in register 3:
LOAD 2 , 0(1) "2 gets a pointer to the father"
LOAD 3 , 1(2) "3 gets the father's age"
LOAD 2 , 1(1) "2 gets a pointer to the mother"
SUB 3 , 1(2) "father's age minus mother's age"
Traversing a pointer is very fast. It takes only one instruction to
do it.
We need to include indexing into the machine language shown in Chapter
6. We used to say that
LOAD 1 , 333
is represented by the number
102 1 333
Let's now say that it is represented by
102 1 0 333
We've introduced a new field into the machine language instruction,
a field that holds the index register's address. That field resides
to the left of the constant address part (the displacement).
We've put a zero in there to indicate that no indexing is desired.
The instruction
LOAD 1 , 333(2)
is represented by
102 1 2 333
We put a 2 into the index register field.
Section 9.8 augments our embedded assembly language to include
indexing.
BOX: How many LOAD instructions would it take to traverse
BOX: a list of length 3, as shown in figure 9.14?
BOX:
BOX: Write an assembly language ~loop that sets register
BOX: 2 to the second bin of each block.
BOX:
BOX: Do you recall the ICL notation for visiting each
BOX: element in a list? (See Section 22.3.1).
9.6
Pointers Consume Memory
If you look at figure 9.3, you will see that we've consumed 16 bins, 3
bins in each of four person blocks, and 4 more bins for the family
block.
Figure 9.4 shows an equivalent representation that consumes
only 12 bins total. We've removed all pointers, and thus saved the
four bins that held pointers.
By replacing a pointer with what it points to, we save the space
taken by the pointer. Are pointers ever cost effective?
9.7
Sharing
Consider figure 9.5. We've added a new block besides the family block,
the social club block. A club block, like a family block, references
its members by pointing to them. Notice that the person blocks for
Mary and Jill are referenced twice.
Figure 9.6 shows the situation with two clubs.
Many person blocks are referenced from more than one point of view.
When a block of memory is referenced more than once, we say that it
is ~shared. When some blocks are shared, the use of pointers is more
efficient than an equivalent rendition without pointers.
Figure 9.7 shows the memory consumed for a non-pointer rendition of
figure 9.6. The non-pointer rendition occupies 36 bins, and the
pointer rendition consumes only 24 bins. We've duplicated the data in
the person blocks. For example, Mary's name, age, and sex appears
three times, once within each of the family and club blocks. These
multiple appearances of the same data wind up consuming more memory
than the rendition with pointers.
With pointers, each reference to a block can be represented using only
one bin, no matter how big the referenced block is. Also, each
additional reference to a block consumes only one additional bin.
With pointers, you can use only one bin to represent an arbitrarily
large amount of information.
The value of sharing can be seen within the scientific literature.
One measure of a scientific article's value is the number of other
articles that reference it. The value rises as the sharing increases.
When a block has N pointers to it, the memory consumed by that block
is shared N ways. Each of the N points of view effectively pays
for approximately 1/N of the memory, instead of 1 (for one copy of the
block). If every block is shared N ways, then the implementation using
pointers costs approximately 1/N the memory used by an implementation
without pointers.
However, much more dramatic savings can occur.
9.7.1
Nested Sharing - Exponential Representation In Only Polynomial Memory
Figure 9.8 shows a structure that has nested sharing. The leftmost
block, block #3, points to a 0 and a 1 block on its right. Those two
blocks share block #2 on their right. Further to the right block #1
is shared.
Starting from the left, you can travel along pointers and travel thru
two shared blocks, blocks #2 and #1. You encounter sharing within
sharing.
Under each of blocks #1, #2, and #3, we have noted how many trips are
possible starting from that block. From block #3, there are eight
possible trips. For each trip, if we write down the "0"s and "1"s
we pass, we will find eight different strings composed of 0's and 1's.
Each trip, from block #3, represents a binary number of length 3.
If we were to extend figure 9.8 from 3 to N stages, there would be
2-to-the-Nth possible trips. Each possible trip corresponds to a
binary number of length N. When sharing occurs within sharing, as it
does here, a linear amount of memory, 6*N bins worth, represents
exponentially many, 2-to-the-Nth, phrases.
Let's begin to replace pointers by copies of what they point to, so
as to remove all sharing from within figure 9.8 (a copy of which
appears as figure 9.9(a)).
Figure 9.9(b) shows what happens when we replace pointers to
block #1 by pointers to unshared copies of block #1. The far
right 0 and 1 blocks have now become shared. To get rid of
that sharing as well, figure 9.9(c) shows the result.
Figure 9.9(d) shows the result after unsharing block #2, with no
sharing remaining. Notice how we've copied the block #2 and everything
to its right. That copying was necessary so as to have no data
shared. The original 3-stage (N) shared representation has expanded
to something with 8 (2^N) blocks at the far right.
Chapter 12 will present a parser that actually explores all possible
rewrites (introduced first in Chapter 1). In that process, we will be
producing and examining an exponential number of distinct phrases, like
these, and yet we will do that consuming only polynomial amounts of
memory and processing time.
As an introduction to parsing, let's consider a possible representation
of phrases, as declared in ICL via:
TYPE PHRASE = [ CHAR: CHAR
RIGHT: PHRASE ] ;
This says that a PHRASE is just a CHARacter followed by another PHRASE
to the right. The phrase "2" is denoted as follows:
[CHAR: '2']
There is nothing to the RIGHT in this short phrase. The phrase 12 is
represented as a PHRASE as follows:
[CHAR: '1' RIGHT: [CHAR: '2'] ]
12 is the character '1' followed by the phrase '2'. Here is a
rendition for 123:
[CHAR: '1' RIGHT: [CHAR: '2' RIGHT: [CHAR: '3'] ] ]
Figure 9.10 shows the memory representation for it.
Recall the first rule in the "Numbers" grammar (Section 1.1.1):
0 -> <DIGIT>
This rule gives us the option to rewrite the character '0' into a
<DIGIT>.
We can represent both possibilities at once, both the before
and the after, as shown in figure 9.11(a).
Figure 9.11(b) shows a '0' appended to the left.
This represents the two phrases:
0 0
0 <DIGIT>
Figure 9.11(c) shows the result of applying our grammar rule this time
to the new '0'. Figure 9.11(d) shows the ~choice of the phrases
starting with '0' or <DIGIT>.
To accomodate this multiplicity of sub-phrases, let's change the RIGHT
field to be a ~set of phrases instead of just a single phrase:
TYPE PHRASE = [ CHAR: CHAR RIGHT: { PHRASE } ] ;
An equivalent declaration that may be easier to think about follows:
TYPE PHRASE = [ CHAR: CHAR RIGHT: CHOICE_OF_PHRASES ] ;
TYPE CHOICE_OF_PHRASES = { PHRASE } ;
In figures 9.11 and 9.8, the rectangular blocks each represents a
CHOICE_OF_PHRASES, and each rounded block represents a PHRASE.
This parsing example shows how nested sharing may be encountered.
Each step that we take to the RIGHT poses a multiplicity of phrases.
Many of those phrases share common sub-phrases even further to the
right.
The sharing arises when we append a character (e.g., '0') to the left
of an existing CHOICE_OF_PHRASES. The grammar tries rewriting the '0'
into <DIGIT>. That new digit PHRASE comes to share with the '0'
phrase the same CHOICE_OF_PHRASES to the right.
When those two phrases beginning with 0 and <DIGIT> are packaged
together as a CHOICE_OF_PHRASES, the possibilities explode, as that
CHOICE_OF_PHRASES may itself become shared from the left as we
append another 0 character to the left, and so on.
BOX: How many distinct phrases can you represent with 36
BOX: blocks, like figure 9.8?
9.7.2
Each Appearance Of A Variable Shares Its Point Of View
How do we specify the data structure shown in figure 9.11(d)? Let's
start by specifying figure 9.11(a). First, we build the rightmost
blocks, the '0' and <DIGIT> blocks, via:
PHRASE_0 := [ CHAR: '0' ] ;
PHRASE_D := [ CHAR: <DIGIT> ] ;
We then form the CHOICE_OF_PHRASES on the left via:
CHOICES_1 := { PHRASE_0 ;
PHRASE_D } ;
We could specify this all at once more simply via:
CHOICES_1 := { [CHAR: '0' ] ;
[CHAR: <DIGIT> ] } ;
CHOICES_1 represents figure 9.11(a).
We go on to build figure 9.11(d) by repeating this process once more.
We first make figure 9.11(c) via:
PHRASE_0 := [ CHAR: '0' RIGHT: CHOICES_1 ] ;
PHRASE_D := [ CHAR: <DIGIT> RIGHT: CHOICES_1 ] ;
Notice that there are ~two appearances of the variable CHOICES_1.
PHRASE_0 and PHRASE_D reference structures that share a common RIGHT
field. We achieve figure 9.11(d) via:
CHOICES_2 := { PHRASE_0 ;
PHRASE_D } ;
Alternatively, we could assign into CHOICES_2 directly from CHOICES_1
via:
CHOICES_2 := { [CHAR: '0' RIGHT: CHOICES_1 ] ;
[CHAR: <DIGIT> RIGHT: CHOICES_1 ] } ;
This latter specification exposes most clearly that CHOICES_2 shares
CHOICES_1 twice. The variable CHOICES_1 appears twice.
Sharing is introduced into data structures by writing expressions that
reference the same variable more than once. The data structure
referenced by that variable will be shared within the new, bigger
data structure being built now. For example, the new data structure
that will be referenced by CHOICES_2 shares within itself the data
structure referenced by CHOICES_1.
Figure 9.9(a) is specified via:
BLOCK_1 := { [CHAR: '0'] ;
[CHAR: '1'] } ;
BLOCK_2 := { [CHAR: '0' RIGHT: BLOCK_1 ] ;
[CHAR: '1' RIGHT: BLOCK_1 ] } ;
BLOCK_3 := { [CHAR: '0' RIGHT: BLOCK_2 ] ;
[CHAR: '1' RIGHT: BLOCK_2 ] } ;
BLOCK_2 is built with two appearances of BLOCK_1, and similarly BLOCK_3
is built with two appearances of BLOCK_2. To get N stages of sharing,
we can write:
BLOCK:= NIL ;
REPEAT N;
DO BLOCK:= { [CHAR: '0' RIGHT: BLOCK ] ;
[CHAR: '1' RIGHT: BLOCK ] } ;
END
Each iteration sets BLOCK so as to reference a data structure that
references twice what BLOCK used to point to.
If sharing were disallowed, then we would deduce that:
BLOCK_2 is greater than twice the size of BLOCK_1
because BLOCK_2 contains two (unshared) copies of BLOCK_1. Similarly:
BLOCK_3 is greater than twice the size of BLOCK_2
because BLOCK_3 contains two copies of BLOCK_2. Also, in the loop
just shown, each iteration would double the size of BLOCK, since
without sharing, the resulting BLOCK holds twice the old BLOCK.
BOX: If a block points to itself, how many possible
BOX: trips are there?
9.7.3
Cycles
Let's reconsider the PERSON and FAMILY data structures. Suppose we
have each person block contain a pointer back to the family in which
the person is a child.
Figure 9.12(a) shows this.
Here are the updated datatype declarations for PERSON and FAMILY:
TYPE PERSON = [ NAME: TEXT
AGE: INT
SEX: BOOL
CHILD_IN: FAMILY ] ;
TYPE FAMILY = [ FATHER, MOTHER, CHILD1, CHILD2 : PERSON ] ;
PERSON now references a FAMILY (in the CHILD_IN field). Notice that
you can start at a PERSON, travel to the FAMILY in which that person
is a child, and then travel back from that family to the same PERSON.
It is possible to take that round trip arbitrarily many times.
We say that a set of blocks contains a cycle if there is at least
one possible round trip, taken by following pointers. The blocks
that you pass on the round trip are said to be members of that cycle.
Data structures with cycles represent infinitely many possible trips.
Figure 9.12(b) shows our database when pointers to
families are replaced by copies of the families. We now have combined
person/family blocks, each consuming 7 bins of memory. There are still
pointers however. The family sub-blocks point to other person/family
blocks.
To get rid of these remaining pointers, we would replace each pointer
to a person/family block by a copy of that person/family block.
Unfortunately, our present 7-bin person/family blocks presume that each
family sub-block consumes only 4 bins. But now, we are proposing that
each of those four bins be replaced by a 7-bin person/family block.
That would raise the size of a family sub-block to 28 bins.
The datatype declarations just shown show that a PERSON references a
FAMILY, and that a FAMILY references PERSON blocks. Let's try to
determine the size of a PERSON block and of a FAMILY block when we
forbid the use of pointers. We know that a PERSON contains a FAMILY,
and so the size of PERSON is strictly greater than the size of a
FAMILY. On the other hand, a FAMILY contains at least two PERSONs,
and so a FAMILY has to be bigger than a PERSON.
How can a FAMILY be larger than a PERSON and vice versa? This
contradiction shows that we can't ever remove all pointers from this
structure. By making at least one of PERSON and FAMILY be a pointer,
we reduce its size to one bin. Once one of PERSON and FAMILY is
reduced to a pointer, the other will have finite size.
Figure 9.12(b) shows the situation if we make PERSON be a pointer (and
not FAMILY). Figure 9.12(a) shows the even more efficient solution
that arises when we make both pointers.
Cycles allow for the representation of infinitely many trips
taken by following pointers. We will encounter cycles in our
representation of semantics in chapter 28, to represent the infinitely
many ways to apply the following rules:
int -> real
real -> int
These rules say that an INTeger can be turned into a REAL with one
application of the first rule, or with that plus arbitrarily many
applications of the pair of rules.
While sharing provides for representing exponentially many possible
trips in only polynomial memory, cycles provide a finite representation
of infinitely many possible trips.
9.7.3.1
At Least One Block In A Cycle Is A Shared Block
Figure 9.13(a) shows a cycle of blocks. From the outside where we
stand, there must be a pointer to one of those blocks. If we can't
get to any block to start with, then those blocks are visible
to no one, and hence they could just as well not exist! The cycle
of blocks would drop out of our universe altogether, like a black
hole, via a background process known as garbage collection (Part 8).
Figure 9.13(b) shows that if those blocks are visible at all, then
there is another entry into the cycle. Thus at least one block in an
accessible cycle has two pointers pointing to it, one pointer from the
cycle and the other pointer from outside the cycle. A cycle
contains at least one shared block.
The phenomenon of cycles can be seen as a special case of sharing.
You can devise algorithms which travel along pointers. You can even
devise algorithms which visit each block exactly once. You avoid
visiting a block more than once by marking each block you arrive at
when you arrive at it. If you encounter a block that is marked, it
means you've already been there before. By retreating at marked
blocks, you will succeed in travel thru each block exactly once.
Just by guarding against travelling thru shared blocks more than once,
you avoid traveling thru a cycle more than once. You always enter a
cycle at a shared block (figure 9.13). After you travel the cycle,
you reencounter that shared block. But since you avoid traveling any
shared block more than once, a second trip around the cycle is avoided.
BOX: What can you say about a cycle that has no shared
BOX: blocks?
9.7.3.2
Exercises
1) How can the use of pointers drastically reduce memory consumption?
2) Figure 9.8 shows eight phrases represented by 9 blocks, as three
stages. How many blocks are required to represent 1024 phrases?
3) How many blocks are required to represent 3-to-the-tenth phrases,
given the characters 0, 1, and 2?
4) Given ~only the rules:
<DIGIT> -> <NUMBER>
<NUMBER> -> <ATOM>
and given the phrase:
<DIGIT> <DIGIT> <DIGIT> <DIGIT> <DIGIT>
How many possible phrases, starting from the far left and ending at the
far right, can be produced by applying all possible rewrites at all
possible places upon this phrase? How few blocks are required to
represent all those phrases, like in figure 9.8 or 9.11?
5) Draw a picture like figure 9.8 or 9.11 to represent concisely the two
phrases that exist when the rule:
<ATOM> + <ATOM> -> <ATOM>
is applied to the phrase
HELLO <ATOM> + <ATOM> GOODBYE
Notice that different PHRASEs in the same CHOICE_OF_PHRASES can have
distinct RIGHT fields.
6) Write an assembly language program that maps figure 9.14(a) to (b).
Feel free to use register 3. This is all done with LOADs and STOREs.
This inserts a new element into a list.
7) Write an assembly language program to delete the second element in the
list referenced by register 1, so as to produce figure 9.14(c).
9.8
Updating The Embedded Assembler To Include Indexing
The following assembly language specification:
LOAD 2 , 333(1)
is written in embedded assembly language as:
LOAD( 2 , 333 \OFF_OF 1 );
That is, the second operand of any instruction (the memory operand)
can use the notation
displacement \OFF_OF register
OFF_OF is a new function. We have called it using ICL's
~infix notation. Any function having exactly two parameters
can be called either the standard way:
OFF_OF( displacement , register )
or the infix way:
displacement \OFF_OF register
By putting a backslash ("\") in front of a function name, it becomes
an infix operator, like "+" (Section 22.1.3).
9.8.1
The New Type MACHINE_ADDRESS_EXPRESSION
The result of OFF_OF, and in fact the second operand in all
instructions, all need to be of the new type declared as follows:
TYPE MACHINE_ADDRESS_EXPRESSION =
[ DISPLACEMENT: ADDRESS_EXPRESSION
INDEX_REGISTER: INT ] ;
For brevity, we will use the short name MAE to denote this new type
MACHINE_ADDRESS_EXPRESSION, and we will use the short AE to denote
our original ADDRESS_EXPRESSION.
A MAE is basically an AE, which appears as the DISPLACEMENT field.
Now an index register can appear as well. An INDEX_REGISTER of zero
will mean "no indexing". The funtion OFF_OF produces a MAE, given
an AE and a register.
To update our embedded assembler, we must make each instruction take in
a MAE instead of an AE. The burden of this change will be placed
on the function that all instructions call, PUT_INSTRUCTION.
PUT_INSTRUCTION will now take in a MAE, and assemble our new 4-field
instructions (instead of the old 3-field instruction encoding).
Once we've upgraded our functions, all of our program specifications
will cease to be legal. Where we wrote:
LOAD(1,333); or LOAD(1,ONE);
we must now supply a MAE instead of the AEs 333 and ONE.
All instructions now require a MAE.
We believe our older specifications still make sense. We know what
we mean when we supply merely an AE. We want that to be interpretted
as a MAE whose INDEX_REGISTER field is zero.
9.8.2
Another Coercion
We provide this implicit transformaion in ICL by declaring:
LET AE BECOME MAE BY
[ DISPLACEMENT: AE INDEX_REGISTER: 0 ]
ENDDEFN
This says that an AE can be seen also as a MAE, whose register
field is zero. This coercion will allow us to keep our old program
specifications.
Thus:
LOAD( 1 , 333 );
will continue to be meaningful, because the 333 (an AE) will be
converted into a MAE. What we've just written will
automatically be equivalent to:
LOAD( 1 , 333 \OFF_OF 0 );
We actually define the \OFF_OF operation via:
DEFINE OFF_OF( X: AE REG: INT ) = MAE:
[ DISPLACEMENT: X INDEX_REGISTER: REG ]
ENDDEFN