116 lines
No EOL
6.9 KiB
Markdown
116 lines
No EOL
6.9 KiB
Markdown
We're still not ready for assembly, though; what we need is a new
|
||
perspective that lends itself to vastly better performance in C++. The
|
||
Life program in the next section is *three to seven times* faster than
|
||
Listing 17.4—and it's still in C++.
|
||
|
||
How is this possible? Here are some hints:
|
||
|
||
- After a few dozen generations, most of the cellmap consists of cells
|
||
in the off state.
|
||
- There are many possible cellmap representations other than one
|
||
bit-per-pixel.
|
||
- Cells change state relatively infrequently.
|
||
|
||
### Bringing In the Right Brain {#Heading8}
|
||
|
||
In the previous section, we saw how a C++ program could be sped up about
|
||
eight times simply by rearranging the data and code in straightforward
|
||
ways. Now we're going to see how right-brain non-linear optimization can
|
||
speed things up by another four times—and make the code *simpler.*
|
||
|
||
Now *that's* Zen code optimization.
|
||
|
||
I have two objectives to achieve in the remainder of this chapter.
|
||
First, I want to show that optimization consists of many levels, from
|
||
assembly language up to conceptual design, and that assembly language
|
||
kicks in pretty late in the optimization process. Second, I want to
|
||
encourage you to saturate your brain with everything you know about any
|
||
particular optimization problem, then make space for your right brain to
|
||
solve the problem.
|
||
|
||
#### Re-Examining the Task {#Heading9}
|
||
|
||
Earlier in this chapter, we looked at a straightforward Game of Life
|
||
implementation, then increased performance considerably by making the
|
||
implementation a little less abstract and a little less general. We made
|
||
a small change to the cellmap format, adding padding bytes off the edges
|
||
so that pointer arithmetic would always work, but the major
|
||
optimizations were moving the critical code into a single loop and using
|
||
pointers rather than member functions whenever possible. In other words,
|
||
we took what we already knew and made it more efficient.
|
||
|
||
Now it's time to re-examine the nature of this programming task from the
|
||
ground up, looking for things that we *don't* yet know. Let's take a
|
||
moment to review what the Game of Life consists of. The basic task is
|
||
evolving a new generation, and that's done by looking at the number of
|
||
"on" neighbors a cell has and the cell's own state. If a cell is on, and
|
||
two or three neighbors are on, then the cell stays on; otherwise, an
|
||
on-cell is turned off. If a cell is off and exactly three neighbors are
|
||
on, then the cell is turned on; otherwise, an off-cell stays off. That's
|
||
all there is to it. As any fool can see, the trick is to arrange things
|
||
so that we can count neighbors and check the cell state as quickly as
|
||
possible. Large lookup tables, oddly encoded cellmaps, and lots of
|
||
bit-twiddling assembly code spring to mind as possible approaches. Can't
|
||
you just feel your adrenaline start to pump?
|
||
|
||
------------------- ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||
 *Relax. Step back. Try to divine the true nature of the problem. The object is not to count neighbors and check cell states as quickly as possible; that's just one possible implementation. The object is to determine when a cell's state must be changed and to change it appropriately, and that's what we need to do as quickly as possible.*
|
||
------------------- ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||
|
||
What difference does that new perspective make? Let's approach it this
|
||
way. What does a typical cellmap look like? As it happens, after a few
|
||
generations, the vast majority of cells are off. In fact, the vast
|
||
majority of cells are not only off but are entirely surrounded by
|
||
off-cells. Also, cells change state infrequently; in any given
|
||
generation after the first few, most cells remain in the same state as
|
||
in the previous generation.
|
||
|
||
Do you see where I'm heading? Do you hear a whisper of inspiration from
|
||
your right brain? The original implementation stored cell states as
|
||
1-bits (on), or 0-bits (off). For each generation and for each cell, it
|
||
counted the states of the eight neighbors, for an average of eight
|
||
operations per cell per generation. Suppose, now, that on average 10
|
||
percent of cells change state from one generation to the next. (The
|
||
actual percentage is even lower, but this will do for illustration.)
|
||
Suppose also that we change the cell map format to store a byte rather
|
||
than a bit for each cell, with the byte storing not only the cell state
|
||
but also the count of neighboring on-cells for that cell. Figure 17.3
|
||
shows this format. Then, rather than counting neighbors each time, we
|
||
could just look at the neighbor count in the cell and operate directly
|
||
from that.
|
||
|
||
But what about the overhead needed to maintain the neighbor counts?
|
||
Well, each time a cell changes state, eight operations would be needed
|
||
to update the counts in the eight neighboring cells. But this happens
|
||
only once every ten cells, on average—so the cost of this approach is
|
||
only one-tenth that of the original approach!
|
||
|
||
*Know your data.*
|
||
|
||
\
|
||
**Figure 17.3** *New cell format.*
|
||
|
||
#### Acting on What We Know {#Heading10}
|
||
|
||
Once we've changed the cellmap format to store neighbor counts as well
|
||
as states, with a byte for each cell, we can get another performance
|
||
boost by again examining what we know about our data. I said earlier
|
||
that most cells are off during any given generation. This means that
|
||
most cells have no neighbors that are on. Since the cell map
|
||
representation for an off-cell that has no neighbors is a zero byte, we
|
||
can skip over scads of unchanged cells at a pop simply by scanning for
|
||
non-zero bytes. This is much faster than explicitly testing cell states
|
||
and neighbor counts, and lends itself beautifully to assembly language
|
||
implementation as **REPZ SCASB** or (with a little cleverness) **REPZ
|
||
SCASW.** (Unfortunately, there's no C library function that can scan
|
||
memory for the next byte that's non-zero.)
|
||
|
||
Listing 17.5 is a Game of Life implementation that uses the
|
||
neighbor-count cell map format and scans for non-zero bytes. On a 20 MHz
|
||
386, Listing 17.5 is about 4.5 times faster at calculating generations
|
||
(that is, the generation engine is 4.5 times faster; I'm ignoring the
|
||
time consumed by drawing and text display) than Listing 17.4, which is
|
||
no slouch. On a 33 MHz 486, Listing 17.5 is about 3.5 times faster than
|
||
Listing 17.4. This is true even though Listing 17.5 must be compiled
|
||
using the large model. Imagine that—getting a four times speed-up while
|
||
switching from the small model to the large model! |