Why is an array operation faster than a loop?

Time a Python loop against the same calculation written as one array operation, and see where the gap comes from.

Because the array operation hands the whole job to compiled code that walks one block of memory, while the loop runs one Python step for every single element. Same answer, different amount of work.

In this lesson I time three pairs: summing two million numbers, turning a million prices into returns, and the same returns through pct_change(). Every timing comes from time.perf_counter, which reads a clock before and after and gives me the seconds in between.

Timings differ from machine to machine and from run to run, so the numbers printed below are the ones from the render, not values I typed in. Yours will differ.

Step 1. Sum two million numbers

np.arange builds the array. The loop adds one element at a time. arr.sum() does the same addition in one call.

import numpy as np
from time import perf_counter

arr = np.arange(1, 2_000_001, dtype=float)   # 1.0, 2.0, ... 2000000.0
print(arr.shape)                             # -> (2000000,)

start = perf_counter()                       # clock before
total = 0.0
for x in arr:
    total = total + x
loop_seconds = perf_counter() - start        # clock after, minus before

start = perf_counter()
fast = arr.sum()
array_seconds = perf_counter() - start

print(total == fast)                         # -> True
print(f"loop:      {loop_seconds:.4f} seconds")
print(f"arr.sum(): {array_seconds:.4f} seconds")
print(f"ratio:     {loop_seconds / array_seconds:.0f} times faster")
(2000000,)
True
loop:      0.1720 seconds
arr.sum(): 0.0017 seconds
ratio:     100 times faster

Both give the identical total. arr.sum() finishes in a couple of thousandths of a second and the loop takes about a tenth of a second, a gap of roughly a hundred times.

Step 2. The same for returns

A return is today’s price over yesterday’s, minus 1. The loop walks the positions. The array version divides the prices from position 1 onwards by the prices up to the second last, all at once.

The prices here are simulated, a random walk of one million steps, so the file stays small and the array stays big.

rng = np.random.default_rng(0)
prices = 100 + np.cumsum(rng.normal(0.02, 0.5, 1_000_000))   # simulated prices, a random walk

print(prices.shape)                                 # -> (1000000,)
print(prices[:3].round(2))                          # -> [100.08 100.04 100.38]

start = perf_counter()
loop_returns = []
for i in range(1, len(prices)):
    loop_returns.append(prices[i] / prices[i - 1] - 1)
loop_seconds = perf_counter() - start

start = perf_counter()
array_returns = prices[1:] / prices[:-1] - 1        # every return in one line
array_seconds = perf_counter() - start

print(len(loop_returns), array_returns.shape)       # -> 999999 (999999,)
print(np.allclose(loop_returns, array_returns))     # -> True
print(array_returns[:3].round(6))                   # -> [-0.00046   0.003401  0.000722]

print(f"loop:  {loop_seconds:.4f} seconds")
print(f"array: {array_seconds:.4f} seconds")
print(f"ratio: {loop_seconds / array_seconds:.0f} times faster")
(1000000,)
[100.08 100.04 100.38]
999999 (999999,)
True
[-0.00046   0.003401  0.000722]
loop:  0.2706 seconds
array: 0.0041 seconds
ratio: 66 times faster

np.allclose compares the two answers element by element and allows for the last bits of floating point rounding. It came back True, so the fast version is the same calculation.

prices[1:] and prices[:-1] are the two arrays being divided: one shifted by a day against the other. A million prices give 999,999 returns, the same count the loop produced.

Step 3. The same in pandas

A pandas Series stores its values in a numpy array, so pct_change() runs the same kind of operation. The loop here reaches into the Series one position at a time with .iloc.

import pandas as pd

s = pd.Series(prices[:100_000])

start = perf_counter()
out = []
for i in range(1, len(s)):
    out.append(s.iloc[i] / s.iloc[i - 1] - 1)
loop_seconds = perf_counter() - start

start = perf_counter()
pandas_returns = s.pct_change()
pandas_seconds = perf_counter() - start

print(np.allclose(out, pandas_returns.iloc[1:]))    # -> True
print(f"loop:         {loop_seconds:.4f} seconds")
print(f"pct_change(): {pandas_seconds:.4f} seconds")
print(f"ratio:        {loop_seconds / pandas_seconds:.0f} times faster")
True
loop:         0.5149 seconds
pct_change(): 0.0016 seconds
ratio:        326 times faster

This gap is wider than in Step 2 on a tenth of the data. Every s.iloc[i] is a lookup through the Series, and the loop does 200,000 of them.

Step 4. What the two versions do

The loop asks Python to do the same paperwork for each element: fetch the value, wrap it in a Python object, look up what + means for that object, store the result. Two million elements, two million rounds of paperwork.

arr.sum() passes the whole array to compiled C code, which reads the numbers straight out of one contiguous block of memory, all of them the same type and the same width, and adds them with no Python involved.

print(arr.dtype)     # -> float64
print(arr.nbytes)    # -> 16000000   <- 2 million values, 8 bytes each, one block
float64
16000000

Write the calculation as an operation on the array, not as a walk over it.

Your turn

Build a = np.arange(1, 1_000_001, dtype=float). Square every element twice: once with a loop that appends to a list, once as a * a. Time both, check they agree with np.allclose, and print the ratio.

import numpy as np
from time import perf_counter

a = np.arange(1, 1_000_001, dtype=float)

start = perf_counter()
squares = []
for x in a:
    squares.append(x * x)
loop_seconds = perf_counter() - start

start = perf_counter()
squares_array = a * a
array_seconds = perf_counter() - start

print(np.allclose(squares, squares_array))    # -> True
print(f"loop:  {loop_seconds:.4f} seconds")
print(f"array: {array_seconds:.4f} seconds")
print(f"ratio: {loop_seconds / array_seconds:.0f} times faster")

The array call has a fixed setup cost, so on a small table the two are both fast and the ratio means little. Here are the 40 AAPL closes from prices.csv, the simulated file used in these lessons, timed in microseconds.

df = pd.read_csv("prices.csv")
close = df.loc[df["Ticker"] == "AAPL", "Close"].to_numpy()

print(close.shape)   # -> (40,)

start = perf_counter()
small_loop = [close[i] / close[i - 1] - 1 for i in range(1, len(close))]
loop_seconds = perf_counter() - start

start = perf_counter()
small_array = close[1:] / close[:-1] - 1
array_seconds = perf_counter() - start

print(f"loop:  {loop_seconds * 1e6:.1f} microseconds")
print(f"array: {array_seconds * 1e6:.1f} microseconds")
(40,)
loop:  94.4 microseconds
array: 48.9 microseconds

Both finish in under twenty microseconds. The gap in Steps 1 to 3 comes from size: the loop pays its per element cost a million times over, the array operation does not.