Profile Before You Optimize

Page content

Cellular Automata From First Principles 49: Profile Before You Optimize

By now we have built dozens of automata.

The natural temptation is to make them faster.

The wrong first question is:

Which optimization trick should I use?

The right first question is:

Where is the time actually going?

Performance work begins with measurement.


Define the workload first

A benchmark is only meaningful if the workload is explicit.

For a cellular automaton, record at least:

grid size
number of channels
neighborhood radius
number of steps
data type
boundary rule
backend

A 64×64 Conway grid and a 1024×1024 multi-channel Lenia system are not the same performance problem.


Time one complete rollout

from time import perf_counter


def benchmark(step_fn, state, steps=200):
    x = state.copy()

    start = perf_counter()
    for _ in range(steps):
        x = step_fn(x)
    elapsed = perf_counter() - start

    return {
        "seconds": elapsed,
        "steps_per_second": steps / elapsed,
        "seconds_per_step": elapsed / steps,
    }

Run the benchmark several times.

The first run may include allocation, cache warm-up, kernel compilation or other one-time costs.


Measure scaling, not one number

sizes = [64, 128, 256, 512, 1024]

for size in sizes:
    state = make_state(size)
    result = benchmark(step, state)
    print(size, result["steps_per_second"])

A scaling curve tells us much more than one headline number.

If doubling width and height gives roughly four times the work, that is expected for a local 2D update.

If runtime grows much faster, something else is happening.


Separate the phases

A step often contains several operations:

neighborhood construction
rule evaluation
conflict resolution
state update
measurement
rendering

Time them separately.

start = perf_counter()
neighbors = compute_neighbors(state)
t_neighbors = perf_counter() - start

start = perf_counter()
next_state = apply_rule(state, neighbors)
t_rule = perf_counter() - start

This prevents us from optimizing the wrong layer.


Rendering can dominate

A surprisingly common mistake is benchmarking simulation and visualization together.

for _ in range(1000):
    state = step(state)
    plt.imshow(state)
    plt.pause(0.01)

This measures a graphics loop as much as it measures the automaton.

For simulation benchmarks:

disable plotting
avoid disk writes
avoid animation encoding
avoid debug logging

Then benchmark visualization separately.


Memory matters too

Fast code that allocates huge temporary arrays may fail at realistic sizes.

For a (2048, 2048) float32 field:

bytes_used = 2048 * 2048 * 4
print(bytes_used / 1024**2, "MiB")

Now multiply that by:

current state
next state
neighbor accumulators
kernel buffers
hidden channels
batch dimension

Performance is often a memory problem before it is an arithmetic problem.


Benchmark correctness with speed

Every optimization should preserve a reference implementation.

expected = slow_step(state)
actual = fast_step(state)

assert np.array_equal(expected, actual)

For floating-point systems:

assert np.allclose(expected, actual, atol=1e-6)

A faster wrong automaton is not an optimization.


Record benchmark metadata

Do not save only:

523 steps/s

Save:

implementation
commit
Python version
NumPy/PyTorch version
device
grid dimensions
channels
steps
seed
elapsed time

Now benchmark results become evidence rather than anecdotes.


The engineering lesson

Performance work should follow the same philosophy we used throughout the book:

observe
measure
form a hypothesis
change one thing
measure again

In the next chapter we will attack one of the most common bottlenecks directly: Python loops over cells.