Performance

Research Software Design by Example


The Problem


Reproducibility

@dataclass
class ParamsSingle:
    """A single set of invasion percolation parameters."""
    kind: str
    width: int
    height: int
    depth: int
    seed: int = None

Saving Prameters

def get_params(filename, cls):
    """Get parameters."""
    with open(filename, 'r') as reader:
        d = json.load(reader)
        return cls(**d)

Using Parameters

def main():
    """Main driver."""
    params = get_params(sys.argv[1], ParamsSingle)
    initialize_random(params)
    grid = initialize_grid(params)
    num_filled = grid.fill()
    if len(sys.argv) > 2:
        print_grid(params, grid, num_filled, details='full')

Performance

@dataclass
class ParamsSweep:
    """A range of invasion percolation parameters."""
    kind: list[str]
    size: list[int]
    depth: list[int]
    runs: int
    seed: int = None

Sweeping Parameter Ranges

def main():
    """Main driver."""
    params = get_params(sys.argv[1], ParamsSweep)
    initialize_random(params)
    results = []
    for p in generate_sweep(params):
        print(p)
        grid = initialize_grid(p)
        t_start = time.time()
        num_filled = grid.fill()
        t_elapsed = time.time() - t_start
        results.append(record_result(p, num_filled, t_elapsed))
    save_results(params, results)

Generators

def generate_sweep(params):
    """Generate next single parameter object."""
    for kind in params.kind:
        for size in params.size:
            for depth in params.depth:
                for run in range(params.runs):
                    yield ParamsSingle(kind, size, size, depth)

Results

Line graph showing that running time increases quadratically with grid size.
Figure 5.1: Running times for various depths and sizes.

That’s a Surprise


Profiling

sys.argv = ["invperc_single.py", "profile_list.json"]
cProfile.run("main()", sort="tottime")

Where the Time Goes

         13885219 function calls in 3.169 seconds

   Ordered by: internal time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
  1794596    1.541    0.000    2.443    0.000 grid_generic.py:47(adjacent)
  8601051    0.872    0.000    0.872    0.000 grid_list.py:20(__getitem__)
      356    0.551    0.002    3.161    0.009 invperc_util.py:12(choose_cell)
  1708120    0.101    0.000    0.101    0.000 grid_generic.py:39(height)
  1731763    0.096    0.000    0.096    0.000 grid_generic.py:35(width)
…more lines…

Better is Possible

Line graph showing that the lazy algorithm’s performance is nearly flat.
Figure 5.2: Running times for various depths and sizes.

Lazy Evaluation


A Lazy Grid

def __init__(self, width, height, depth):
    """Construct and fill."""
    super().__init__(width, height, depth)
    self._candidates = {}

Lazy Filling

def fill(self):
    """Fill grid one cell at a time."""
    x, y = (self.width() // 2, self.height() // 2)
    self[x, y] = 0
    num_filled = 1
    self.add_candidates(x, y)
    while True:
        x, y = self.choose_cell()
        self[x, y] = 0
        num_filled += 1
        if self.on_border(x, y):
            break
    return num_filled

Adding Candidates

def add_candidates(self, x, y):
    """Add candidates around (x, y)."""
    for ix in (x - 1, x + 1):
        self.add_one_candidate(ix, y)
    for iy in (y - 1, y + 1):
        self.add_one_candidate(x, iy)
def add_one_candidate(self, x, y):
    """Add (x, y) if suitable."""
    if x < 0 or x >= self.width() or y < 0 or (y >= self.height()):
        return
    if self[x, y] == 0:
        return
    value = self[x, y]
    if value not in self._candidates:
        self._candidates[value] = set()
    self._candidates[value].add((x, y))

Choosing a Cell

def choose_cell(self):
    """Choose the next cell to fill."""
    min_key = min(self._candidates.keys())
    available = list(sorted(self._candidates[min_key]))
    i = random.randrange(len(available))
    choice = available[i]
    del available[i]
    if not available:
        del self._candidates[min_key]
    else:
        self._candidates[min_key] = set(available)
    self.add_candidates(*choice)
    return choice

It’s Faster


Exercises

  1. FIXME: add exercises for performance profiling

  2. Modify the list and array implementation to collect candidate cells of equal lowest value and select one of those.

  3. Does it make sense to pre-populate candidates by adding all cells in the grid at the start of the program? Why or why not?

  4. FIXME: test lazy approach with randomnmess