"almost all programming can be viewed as an exercise in caching"

51 views
Skip to first unread message

Robert Prins

unread,
Mar 8, 2016, 5:58:21 PM3/8/16
to
Yes, the line that has been in Terje Mathisen's email signature for about a
zillion years...

But how far can you actually go caching data?

As an example I will use the development of the program I use to extract all
kinds of weird, wonderful and mostly just totally useless statistics from the
data I collect when I'm hitchhiking.

Let's start at the beginning...

Sometime, like well over 20 years ago, the timestamps of the oldest files that I
still have is 1994-03-26, I wrote a Turbo Pascal (V3.01a) program to help me to
process some of my hitchhike statistics. The program was tiny, just a 49kb .COM
file, and did what it had to do. In the years that followed it slowly grew,
because I added more and more tables, (Driver: "Have you ever calculated this or
that?" Me: "No, but it might be interesting...") and the last (60th) Turbo
Pascal V3.01a version, with files dated @ 1996-08-05, came in at a hefty 54kb. :)

As a side note, in my day-to-day job I work as a PL/I programmer on z/OS, so I
also translated the Pascal code to PL/I and the oldest (of currently 35)
versions written in that language dates back to 1995-10-22.

From version 46 (1995-07-30) the code contained lots of inline (the old type
inline, with hexadecimal codes) code, and from version 51 (1995-10-13) yours
truly started adding 0x66 (386) prefixes to it. The last TP 3.01a version (60)
saw the light on 1996-08-05, when ...

A fellow contractor at KLM gave me his no longer being used version of TP 6.

Given that my carefully byte counted TP 3.01a inline code sometimes crossed
actual Pascal code, the first TP 6 version (obviously) didn't run, and I had to
convert all inline code back to normal Pascal, ouch!

The last (53rd) TP 6 version saw the light on 2008-10-06, and the reason for
abandoning Turbo Pascal was the fact that it could no longer cope with the
amount of data, at the time I had hitched 2,206 rides and the input file had
reached a size of 502kb, way too much to handle in the 16-bit DOS environment,
even using using a special CONFIG.SYS menu and AUTOEXEC.BAT that left most of
memory free, and a unit that extended the TP heap into unused UMBs.

I had to change again, and having been a lifelong user of Pascal (and its rather
more functional grandparent, PL/I), I choose to go for Virtual Pascal, a 32-bit
clone of Turbo/Borland Pascal. I did think about FreePascal at the time, and
actually gave it a try, but in the end (don't ask me for the then "Why?") it
didn't cut it, and having tried it again several times in the past few years, it
still doesn't cut it, the problem *seems* to be that FP uses a different
philosophy with regards to alignment of data, which even causes the "pure"
Pascal version to generate code that eventually results in a GPF.

Obviously, having recovered from the TP 3.01a "inline" to TP 6 conversion
fiasco, I had made sure that nothing of this nature would ever occur again, by
keeping two parallel versions of the TP 6 source, a "pure" Pascal one, and one
with inline assembler, and other than having to change a few registers in one
not entirely "pure" Pascal routine, some integers to longint's and, although
this happened somewhat later and wasn't trivial, all Borland 6-byte REALs to
IEEE doubles, the first Virtual Pascal version was nearly identical to the last
TP6 version. (I'm currently working on the 93rd version coded in VP)

Besides the fact that VP is a 32-bit compiler, which means I will never run out
of space again, it's a real clone of the Borland software, which means that the
generated code isn't, to say it very politely, much better than the TP 6 (AD
1990) or even TP 3.01a (AD 1985) code, but unlike the Borland compilers, it
actually produces an assembler listing that shows the generated code and as a
result I've spent quite a bit of time over the last few years hacking code from
Pascal to inline assembler. I usually let the compiler do the heavy lifting when
it comes to new code, but once the Pascal code is working, the routine is
duplicated between conditional compilation tags, and, using the original
assembler listing as a starting point, converted into inline assembler, which in
many, if not most, cases now only vaguely resembles the original code, I try to
keep data in registers, avoid the use of the VP RTL where possible, and if ebx,
esi and edi, the inter-procedural to-be-saved registers aren't used in the
calling chain, I will remove the saving and restoring of them, or save them in
the calling procedure, rather than in the called one. The result of all of this
fiddling? A program that is around 35% faster and nearly 25% smaller that the
pure Pascal version... In (slightly outdated, I started writing this post in
December 2015) figures, with the running time averaged over 10 runs?

Inline assembler version: 76,288 bytes, running time 0:00:00.23 sec
Pure Pascal version : 101,376 bytes, running time 0:00:00.35 sec

Yes, all this work for a program that runs in a fraction of a second...

So why this posting?

In the past years I've posted a few snippets of the program:

Subject: Optimization problem
Message-ID: <85akp6...@mid.individual.net>
Date: Sun, 16 May 2010 18:28:26 +0000

Thread ran to 70 postings, and in the end I actually found the bug in the code
sent to me by Paul Green way back in May 1998, which does not require any
sorting, although it (currently) does an excessive(?) amount of allocating and
freeing of memory via the RTL - 858 allocates and frees at the last run of the
equivalent PL/I program. (This could, in theory and obviously in practice too,
be avoided by pre-allocating, once, an array to hold the data)

Subject: x86 to x86-64 conversion issue
Message-ID: <9lppuk...@mid.individual.net>
Date: Mon, 26 Dec 2011 01:29:22 +0000

Thread ran to 17 postings, I'm using Steven Fürst's solution, due to lack of
support for anything over MMX in Virtual Pascal :(

Subject: Replacing single byte in doubleword
Message-ID: <9v7tt9...@mid.individual.net>
Date: Wed, 18 Apr 2012 15:29:19 +0200

Tread ran to 12 postings, I'm using Terje's solution :)

So I'm now going to be a heretic, and abandon x86 in favour of PL/I, as that
makes it a bit easier to show the data-structures I'm working with.

When I read in the data, from a simple codepage 437 spaced-out CSV file
(currently just over 1Mb, and containing the data for the 3,725 recorded rides
I've had since 1980-06-16T07:47), all non-meta-data ends up in a linked-list,
that looked like this in the first PL/I program, with a second copy below, the
way it looks now. For those who are not familiar with PL/I, a quick explanation
about datatypes:

ptr : pointer (32 bit)

char(n) : character string of length(n)

fixed bin(n): basically an integer of n bits with a 1-bit sign. Newer versions
of PL/I allow the attribute "unsigned". No restrictions on n (up to 63/64), but
in practice only n=7/15/31/64 (signed) and n=8/16/32/64 are used, other values
should lead to immediate dismissal...

fixed(p,q) : short for "fixed dec(p,9), fixed point decimal, p total digits, q
fractional digits, storage requirements trunc((p+1)/2) bytes, the last four bits
are used for the sign.

bit(n) : bit string of n bits, best used with the attribute "aligned",
which makes it occupy one byte.

The 'init (whatever)' will initialize the various fields to "whatever" upon
allocation of the structure, a very neat feature!

"sysnull()" is a PL/I built-in function that returns one (0x00000000) of the two
PL/I equivalents of "nil" - the other (original AD 1964, from the days
System/360 used 24-bit addressing) built-in is "null()", which returns 0xFF000000.

From LIFT001, 1994-10-22:

dcl 1 lift_list based(lift_ptr),
2 lift_nxt ptr init (sysnull()),
2 lift_prv ptr init (prv_ptr),
2 trip fixed (3) init (liftrec.trip),
2 ride fixed (3) init (liftrec.ride),
2 day char (3)
init (translate(liftrec.day, ' ', ',')),
2 km fixed (7,1) init (liftrec.km),
2 time fixed (7) init (0),
2 v fixed (5,1) init (liftrec.v),
2 vx fixed (15,12) init (0),
2 nat char (3)
init (translate(liftrec.nat, ' ', ',')),
2 type char (4)
init (translate(liftrec.type, ' ', ',')),
2 country char (3)
init (translate(liftrec.country, ' ', ',')),
2 wtime fixed (7) init (-1),
2 split char (1) init (liftrec.split),
2 date,
3 year fixed (5) init (liftrec.date.year),
3 month fixed (3) init (liftrec.date.month),
3 day fixed (3) init (liftrec.date.day),
2 jdn fixed (7) init (-1),
2 ferry bit (1)
init ((liftrec.ferry = ':')) aligned,
2 padding char (35) init ((35)' '),
2 ll_nxt ptr init (sysnull()),
2 tr_nxt ptr init (sysnull()),
2 tc_nxt ptr init (sysnull()),
2 tl_nxt ptr init (sysnull()),
2 cl_nxt ptr init (sysnull()),
2 nl_nxt ptr init (sysnull()),
2 yr_nxt ptr init (sysnull()),
2 yc_nxt ptr init (sysnull());

From LIFT035, 2016-01-01:

dcl 1 lift_list based(lift_ptr),
2 lift_nxt ptr init (sysnull()),
2 trip fixed bin (31) init (liftrec.trip),
2 ride fixed bin (31) init (liftrec.ride),
2 lift_prv ptr init (prv_ptr),
2 id fixed bin (31) init (0),
2 day fixed (3) init (-1),
2 cday char (3) init (liftrec.cday),
2 km fixed (9,1) init (liftrec.km),
2 time fixed (9) init (0),
2 vx fixed (13,9) init (0),
2 nat char (4)
init (translate(liftrec.nat, ' ', ',')),
2 type char (4)
init (translate(liftrec.type, ' ', ',')),
2 cnty char (4)
init (translate(liftrec.cnty, ' ', ',')),
2 ferry bit (1)
init ((liftrec.ferry = ':')) aligned,
2 wferry bit (1)
init ((liftrec.dt = ':')) aligned,
2 wtime fixed bin (31) init (-1),
2 #wa fixed bin (31) init (-1),
2 date,
3 year fixed bin (31) init (liftrec.date.year),
3 month fixed bin (31) init (liftrec.date.month),
3 day fixed bin (31) init (liftrec.date.day),
2 dow fixed bin (31) init (-1),
2 dtime fixed (5) init (-1),
2 atime fixed (5) init (-1),
2 split char (1) init (liftrec.split),
2 cross bit (1)
init ((liftrec.dt = 'x')) aligned,
2 etime fixed (5) init (0),
2 itime fixed (5) init (0),
2 jdn fixed (7) init (-1),
2 aitime fixed (5) init (-1),
2 odo fixed (7,1) init (liftrec.odo),
2 deploc char (43) init (liftrec.deploc),
2 arrloc char (43) init (liftrec.arrloc),
2 padding char (253) init ((253)' '),
2 wid fixed bin (31) init (0),
2 #sd fixed bin (31) init (0),
2 #ld_l fixed bin (31) init (0),
2 wift_nxt ptr init (sysnull()),
2 wift_skip ptr init (sysnull()),
2 owner ptr init (sysnull()),
2 ll_nxt ptr init (sysnull()),
2 tr_nxt ptr init (sysnull()),
2 tc_nxt ptr init (sysnull()),
2 tl_nxt ptr init (sysnull()),
2 xty_ptr ptr init (sysnull()),
2 cl_nxt ptr init (sysnull()),
2 xco_ptr ptr init (sysnull()),
2 nl_nxt ptr init (sysnull()),
2 xna_ptr ptr init (sysnull()),
2 yr_nxt ptr init (sysnull()),
2 yc_nxt ptr init (sysnull());

Other than the fact that the second structure contains a bit more "real" data,
it also contains six additional pointers, so let's take all of the pointers out
and explain how they are used (to cache things):

First of all we've got the two "standard" pointers for a linked list, to the
next and previous items:

- lift_nxt : the address of the next item in the list of ALL rides
- lift_prv : the address of the previous item in the list of ALL rides

To understand some of the pointers below, here's a tiny bit of the
reduced-to-interesting-bits-only, input file, compressed avoid line-wraps. The
fields end up in a structure called "liftrec", which is basically an overlay
over the line, and their meanings are:

a : the trip
bb : the ride in this trip
ccc : the "day" in this trip (or meta-day, or n-th split)
ddddd : the distance for this ride (or partial ride split)
eeee : the pure driving time for this ride
fffff : the rounded average speed for this ride (ddddd/eeee)
gg : the nationality of the driver
hh : the type of the driver (Male/Female/Truck/Van/etc) or type of split
ii : the country (or meta-country) for this ride
jjjj : the waiting time for this ride
k : the type of split
LLxLL : departure time of the ride (x may contain meta-data)
mmmmm : arrival time of the ride
nnnnnnnnnn: departure date of the ride, only on first ride of a day

a bb ccc ddddd eeee fffff gg hh ii jjjj k LLxLL mmmmm nnnnnnnnnn
1, 1, 1, 3.2,0.05, 38.4,NL,V, NL, , , 7.47, 7.52,1980-06-16
1, 2, 1,135.0,1.13,111.0,NL,-, NL,0.20, , 8.12, 9.25,
1, 3, 1, 12.0,0.18, 40.0,NL,-, NL,0.33, , 9.58,10.28,
1, 3,! 1, , , , , !C, , ,!,10.08,10.20,
1, 4, 1, 31.0,0.37, 50.3,D, -, *, , ,10.49,11.33,
1, 4, 1a, 23.0,0.24, ,D, -, NL,0.21,*, , ,
1, 4, 1b, 8.0,0.13, ,D, -, D, ,*, , ,
1, 4,! 1, , , , , !B,NL, ,!,11.13,11.20,
1, 5, 1, 19.0,0.16, 71.3,D, -, D, 0.28, ,12.01,12.17,
.
.
.
1,45, 12, 34.0,0.16,127.5,S, -, S, , , 7.25, 7.41,1980-07-06
1,46, 12, 25.0,0.20, 75.0,S, V, S, 5.09, ,12.50,13.10,
1,47, #,924.0,9.30, 97.3,N, -, *, , ,13.41, ,
1,47, 12,687.0,6.30, ,N, -, , ,#, , ,
1,47, 13,237.0,3.00, ,N, -, , ,#, , 3.15,1980-07-07
1,47,12a,157.0,1.40, ,N, -, S, 0.31,*, , ,
1,47,12b,202.0,2.00, ,N, -, DK, ,*, , ,
1,47,12c,413.0,4.02, ,N, -, D, ,*, , ,
1,47,13d,152.0,1.48, ,N, -, NL, ,*, , ,
1,47,! 1, , , , , !B,S, ,!,15:21,17.00,
1,47,! 2, , , , , !B,DK, ,!,19:00,21.00,
1,47,! 3, , , , , , , ,!,21.10,21.15,
1,47,! 4, , , , , , , ,!,23.55, 0.05,
1,47,! 5, , , , , !B,D, ,!, 1.17, 1.27,
2, 1, 1,

More info about the format of the input file, and all its (too) many
idiosyncrasies can be found at <http://hitchwiki.org/en/Keeping_statistics>. The
manual for the Pascal programs can be found at
<http://hitchwiki.org/en/User:Prino/pascal>. The programs, their sources and
input data can be found on my Google Drive at <https://goo.gl/EN3xeN>. You're
likely to be only interested in the four .RAR files and maybe in lift &
v100+.ods and LibreOffice spreadsheet that contains some graphs. If you want to
play with the PL/I version (also there as lift.pli) shout and I'll provide the
Compile, Link & Go JCL, or a z/OS load module (Enterprise PL/I V4.3 ARCH(10))
and some "Go" JCL. Input file here is lift.dat (extracted from liftdat.zip)
uploaded to a RECFM=VB,LRECL=259 format. Note that the Pascal programs contain
both "pure" Pascal as well as "pure inline assembler" versions of most
procedures, but that my comments are somewhat sparse...

============
- owner : the address of first item for this ride

This is the first "caching" pointer, pointing to the first item of a ride, i.e.
if a ride consists of more than one input record, this pointer will point to the
first item for the ride, avoiding having to use a loop using lift_prv to go back
to the first item. In the above lift_prv in the "1,47,! 5" item would point to
the "1,47, #" item

============
- ll_nxt : the address of the next item in the list of ALL rides, skipping any
records containing data relating to day or country splits

This pointer is used to hop from "1, 4, 1" to "1, 5, 1", skipping the three
items for ride 4 where field "k" <> ' ', avoiding a forward loop using lift_nxt

============
- tr_nxt : the address of the next ride for the same trip
- tc_nxt : the address of the next ride for the same trip, skipping any
records containing data relating to day or country splits

These two pointers, anchored in a not-shown list containing data-per-trip allow
immediate access from a trip to the rides in that trip. tr_nxt is analogous to
lift_nxt, in that it points to the next item, tc_nxt, like ll_nxt skips "k" <> '
' items

============
- yr_nxt : the address of the next ride for the same year
- yc_nxt : the address of the next ride for the same year, skipping any
records containing data relating to day or country splits (currently unused)

These two pointers are similar to tr_nxt and tc_nxt, but connect the items in
the lift_list to their corresponding year

============
- tl_nxt : the address of the next ride for the same type of driver or vehicle
- cl_nxt : the address of the next ride for the same country, skipping any
records containing data relating to day splits and records containing multiple
country totals
- nl_nxt : the address of the next ride for the same nationality of the driver

These three pointers, anchored in (again) not shown lists of Types, Countries
and Nationalities build sub-lists of rides with the same Type, Country or
Nationality, i.e. tl_nxt in "1, 1, 1" would point to the next item where "hh" =
'V' (the not shown "1,13, 2". Using these pointers I can move through the
various sublists of Types, Countries, and Nationalities without using a loop
with lift_nxt and a check for the same Type, Country or Nationality.

============
- wift_nxt : the address of the next item in the list of waits
- wift_skip: the address of the next item in the list of waits for a specific
type of wait (all/trip/country/year)

These are actually not really "caching" pointers, they were added recently to
allow me to use the lift_list items in lieu of a separate list of items that
have to do with waiting info.

============
- xty_ptr : the address of the type_list for the type of this ride
- xco_ptr : the address of the cnty_list for the country of this ride

- xna_ptr : the address of the nat_list for the nationality of this ride

During the process of reading in the input file, I create, in-line, four lists,

1) obviously a list of all rides, plus
2) a list of trips, plus
3) a list of years, plus
4) a list of (calendar) days

I also build strings of Types, and Countries and Nationalities, the latter two
in alphabetic order. After reading in the entire input file, these strings are
parsed and I build lists of Types, Countries, and Nationalities.

During the very first pass through the entire list (generating the "Summary"
output data), the xty/co/na_ptr pointers are linked to the correct
type/cnty/nat_list items, so that on subsequent passes (generating output per
trip and per year), it is no longer necessary to scan the entire
type/cnty/nat_list from the top. For what it's worth, access to these lists
while generating the "Summary" output is also cached, given that once I'm in a
country, it's pretty likely that the next ride will be in the same country, so
rather than scanning the cnty_list from the top, I first compare the
lift_list.cnty with the current one on the cnty_list.

Again, all this work for a program that runs in less than a second...

Now I have two questions:

1) Without actually looking at the code (it's as mentioned before a bit (too)
sparsely commented), but with what I've written above, can anyone think of more
ways to squeeze a few more micro-seconds out of the code, or ways to cache even
more accesses. I'm especially interested in a way to speed up access to the
correct item in the nat_list if I do not catch a cached nationality. I currently
have 87 nationalities, and although the code, here as PL/I:

process_nationality: proc;
/* xn?_ptr will only be set in summary processing */
if ^@summary then
nat_ptr = lift_list.xna_ptr; /* Direct hit! */
else
if s_na_ptr -> nat_list.nat ^= lift_list.nat then
do;
/* No match, scan from top or middle :( */
if lift_list.nat >= nat_mid then
nat_ptr = nat_mptr;
else
nat_ptr = nat_top;

do nat_ptr = nat_ptr repeat nat_nxt
while(nat_ptr ^= sysnull())
until(nat_list.nat = lift_list.nat);
end;

s_na_ptr = nat_ptr;
end;
else
nat_ptr = s_na_ptr;

nat_list.#c = nat_list.#c + 1;
nat_list.km = nat_list.km + lift_list.km;
nat_list.time = nat_list.time + lift_list.time;
end process_nationality;

is for all but the summary very efficient, my "feeble" attempt to halve at least
the number of iterations in the inner loop by testing for a halfway item still
result in substantial statement (PL/I) & cycle (x86) counts. Given that there
are less than 256 different nationalities in this planet, some smart way of
hashing a four-byte nationality code into a unique index would be very nice. I
know there is hashing code in my disassembly of Borland's TP6 code that does
this to look up keywords and opcodes, but I cannot find it right now, it's
probably still on one of many uncataloged floppy disks... However, I do have
doubts about the speed of this, compared to the very simple inner loop code of

@02:
cmp [eax + offset nat_list.s_nat], edx
je @03

mov eax, [eax] { nat_list.nat_nxt is first element in list! }
test eax, eax
jnz @02 { there must be a match, so no test, and just jmp? }

@03:
mov nat_ptr, eax

An alternative would be to move the pointers to the nat_list items into an array
and do a binary search, which should yield a result in never more than 8 compares...

To end this ridiculously long post, some final notes:

1) I'd love to get some feedback on the quality (or lack of it) of my code. I'm
well aware that most people in this group would rather write code than look at
the code of others, but maybe...

2) And for the third time, all this work for a program that runs in less than a
second? So a little background information... Demand for PL/I programmers on
z/OS is low. After having lost my job more than six years ago, I've had about a
dozen interviews, gatecrashed two German GSE PL/I+COBOL meetings, all without
much result. In order not to go completely mad, you can do only so much around
the house, my wife occasionally, in a friendly way, kicks me out onto the street
with a "Go HH'ing for a few days, I need some rest (but don't get into cars with
young blonde women...)" and when I come back, have entered the data into
"lift.dat", have run "allhitch.bat", I usually spend a bit of time looking at
the code, and while doing so after my trip in January, I realised that
processing the input file led to some code needlessly being executed multiple
times, in the above segment of the input file, the first seven "1,47,..."
entries are all for a Norwegian male driver (the late Roger Albertsen, who
played for FC Den Haag at the time), so why bother checking to see if Norway and
"male" have already been added to the strings of nationalities and types more
than once? Looking at the code not having looked at it in some cases for quite a
long period will, at least for me, sometimes make me realize that it can be done
differently, and differently usually means better, and the following is probably
the strongest example of this,

3) While working on the PL/I code sometime in late 2012/early 2013, I realized
that cleaning up linked lists could be done far more efficiently if PL/I would
allow me to allocate storage in an "AREA" variable, but in discrete amounts.
Using such an approach would allow the entire linked list (or far more complex
allocated at run-time tree/graph/etc structures) to be cleared in a single PL/I
"AREA=EMPTY();" statement, which translates into a single(!) z/OS assembler
instruction to move 16-bytes of storage into the control part of the AREA
variable. A bit of simple detective work revealed the undocumented layout of the
control part of the AREA, allowing me to test my hypothesis, and on 26 February
2013 I entered an RFE (Request For Enhancement) on the IBM website, see
<https://www.ibm.com/developerworks/rfe/execute?use_case=viewRfe&CR_ID=31632>.
My request made it into Enterprise PL/I V4.4, which was released a mere seven
months later, in September 2013... In other words, by thinking about what I've
been doing, and about how it can be done better, Fortune 1000 companies will
(potentially) be saving substantial amounts of CPU charges in the years to come.

To finally end this posting with the hope that no-one of you will mind if I, on
occasion, come back with more questions on how to shave a few more milliseconds
from my program. ;)

Robert
--
Robert AH Prins
robert(a)prino(d)org
Reply all
Reply to author
Forward
0 new messages