Programming Corner from Icon Newsletter 26
March 1, 1988; Icon Version 7
Timing Expressions
In the last Newsletter we promised more results from benchmarking Icon expressions
to see how fast they execute. The timings that follow were obtained under
Version 7 of Icon. In most cases, there should be little difference between
Version 6 and Version 7 timings.
Of course, absolute timings vary greatly from computer to computer. The
timings that follow are relative to a mythical "Icon execution cycle".
Relative timings may vary somewhat depending on the computer used, the C
compiler, and so forth. Although we have not collected data on differences
in relative timings for different implementations of Icon, such differences
usually should be small enough to ignore. (This remains to be shown, however.)
For reference, the relative timings that follow were obtained from running
Icon on a VAX 8650 under UNIX 4.3bsd.
To provide some values for timings, suppose values are assigned to variables
as follows:
i := 10
j := 20
r1 := 3.0
r2 := 2e10
Addition is about as simple and conventional operation you can imagine.
Here are some timings:
i + j 4.0
r1 + r2 5.6
i + r1 7.3
You probably would expect real (floating-point) arithmetic to take a little
longer than integer arithmetic, but that's not what accounts for the difference
in timings for the first two expressions. Icon real numbers are stored in
small blocks that have to be allocated (12 bytes each). It's the allocation
time that accounts for most of the difference above. (The time required
for possible garbage collection as a result of this allocation is not included
in the figures above.) The reason why the addition of an integer and a real
takes even longer is because of conversion of the integer to a real.
Now consider some operations on strings. Suppose
s1 := "abcdef"
s2 := string(&cset)
One simple operation is computing the size of a string:
*s1 2.3
*s2 2.3
As these figures suggest, the time it takes to determine the size of a string
does not depend on how long the string is. In fact, the size of a string
is computed when the string is created and is stored as part of the string
value -- it's right there when it's needed.
String comparison illustrates how subtle some operations are and how difficult
it is to know much time it takes to perform some operations. We'll use the
following strings in lexical comparisons:
s1 := "abcdef"
s2 := "abc" || "def"
s3 := repl(s1,100)
s4 := repl(s1,100)
The reason for having duplicate strings will become apparent in the timings:
s1 == s1 3.8
s1 == s2 5.6
s1 == s3 3.8
s3 == s4 60.6
What's going on here? It takes the same amount of time to compare a string
to itself as it does to compare a string to a much longer string. But comparing
two strings with the same value takes longer! The reason why there is a
difference in the first two timings is that s1
and s2
are physically distinct, even though they have the same value. (That's
a property of the way Icon is implemented, not of the language itself.)
What happens in string comparison is that two checks are made right away.
First, are the values physically the same, as in the first expression above?
If so, comparison succeeds without even looking at the characters. Second,
are the lengths different, as in the third case above? If so, the comparison
fails immediately. Only if neither of these cases apply are the characters
compared. The comparison is from left to right, character by character,
until there is a mismatch (failure) or there are no more characters (success).
Consequently, string comparison takes the longest when the two strings are
physically different (produced in different computations) and have a long
common initial substring.
There's not much you can do about this when programming -- and you probably
shouldn't try to -- but this information may keep you from jumping to unwarranted
conclusions and doing things that may be counterproductive.
If timings are not intuitive for simple expressions, what about something
more complicated, like operations on a set? To begin with, how time consuming
is it to construct a set?
set() 13.9
That's probably less than you'd expect. (The expression above uses a feature
of Version 7 which allows the first argument of set to be defaulted rather
than requiring an empty list, as in Version 6.) A word of caution, however
-- space has to be allocated for a set; while the figure above includes
the time for allocation, it does not account for time that this allocation
may subsequently incur in possible garbage collections.
The next obvious question is how long does it take to insert a member in
a set. If we start with an empty set S
, the timing is
insert(S,1) 15.3
As you might imagine, that figure doesn't mean much, since how long it takes
to look up a value in a set must depend on how big the set is and what its
members are. Suppose S
contains the first 1,000 integers, as
in
S := set()
every set(insert(1 to 1000)
Here are some figures for looking up integers in S
, as well
as an integer that's not in S
:
member(S,1) 9.2
member(S,500) 7.5
member(S,799) 12.5
member(S,1001) 14.1
To begin with, it should seem reasonable that it takes longer to find out
that a value is not in the set -- whatever technique is used for look up,
one way or another, everything has to be checked, while if the value is
in the set, it it may be found more quickly. But why does it take longer
to find 1 than 500? (The difference in timings is real, incidentally.) Can
you guess anything about how sets are implemented from these figures? And,
much more importantly, is it faster to look up integers than, say, strings?
Without trying to answer all these questions (rather we hope they will make
you think and possibly dig deeper into the internal workings of Icon), we'll
just comment that timings for a language like Icon with all its features
are possible combinations, is not really subject to reduction to a few simple
formulas, guidelines, or tables of timings. We hope (someday, if there's
ever time) to compile an extensive list of timings, but the result is more
likely to be a curiosity than a useful tool for programmers.
Storage Allocation
Another dimension of expression evaluation relates to the storage that may
be allocated. The timings above account for the time required for allocation,
but they don't give an insight into how much storage is involved or the
time that may be needed later on for garbage collection.
There are several factors involved here. The space allocated for an object
may be transient and used only temporarily, until another value takes its
place. That's true in expressions like
while line := read() do
if check(line) then write(line)
where space is allocated for each string that is read and assigned to line
,
but then is replaced by the next string. On the other hand, a set or table
that is used to hold many values may last from the beginning of program
execution until the end, tying up storage all the time. Transient allocation,
involves "storage throughput", in which space that is no longer
needed can be reclaimed by garbage collection.
The interesting thing is that garbage collection spends most of its time
working storage that has to be retained; it barely notices storage that
is "garbage". For this reason, storage throughput, as exemplified
by the loop above, is comparatively cheap in itself, but if there is a lot
of it, it does cause garbage collections. Such collections are fast if there
is a lot of garbage, but if there are a lot of "permanent" object
like sets and tables around, they pay for it each time.
What all this means is that there is no simple formula for associating a
timing penalty for garbage collection with storage allocation. It depends
on the storage environment, and in a complicated way.
Nonetheless, it may be interesting to know how much space various kinds
of objects take in Icon. This is something that can be expressed in formulas
and presented in tables (this is done in the Icon implementation book).
However, there are some things about storage allocation that you might not
expect.
For example, a string takes only as many bytes of storage as there are characters
in it (unlike C, Icon's strings are not null-terminated.) For example,
s := repl("x",100)
takes 100 bytes of storage. But what about the following expression?
s[1] := "y"
This expression does not actually change the former value of s
(another variable might be sharing the value and must not have its value
changed as the result of changing the value of s
). Instead,
the expression above is a shorthand notation for concatenation and assignment
of a new value to s
:
s := "y" || s[2:0]
Consequently, you'd expect this operation also to take 100 bytes of storage.
It does take 100 bytes of string storage, but it also allocates 20 bytes
for a substring trapped variable block that is used to keep track of the
substrings involved and the variable to which the assignment is made. Something
like this is necessary, since in the general case, a lot might go on between
the subscripting operation and the final assignment. For example, in
s[1] := compute()
there's not telling what compute may do before an assignment is made to
s
.
Substring trapped variable blocks contribute to storage throughput, since
they are needed only until the assignment is made, which usually is right
away. Unfortunately, the present implementation of Icon is not smart enough
to detect when substring trapped variables are needed -- it even allocates
them when no assignment is made, as in
write(s[i])
Nontheless, such blocks are transient. They may cause garbage collection,
but getting rid of them is fast. And the implementation could be improved
so that substring trapped variable blocks are allocated only when they are
actually needed.
Icon home page