Performance & Vectorization
Definition
NumPy’s speed comes from vectorization (pushing loops into compiled C code) and contiguous memory layout. Understanding these mechanics helps write code that’s orders of magnitude faster than equivalent pure-Python loops.
Why Vectorization Matters
# SLOW — pure Python loop
result = []
for x in arr:
result.append(x ** 2 + 1)
# FAST — vectorized (runs in compiled C, no per-element Python overhead)
result = arr ** 2 + 1graph LR A["Python loop per-element interpreter overhead"] -->|100x+ slower| B["Vectorized NumPy op single compiled C loop"]
Rule of thumb
If you’re writing a
forloop over a NumPy array’s elements, stop and ask: “is there a ufunc, broadcasting expression, or boolean mask that does this instead?” Nearly always, yes.
Memory Layout — C-order vs Fortran-order
arr.flags # shows C_CONTIGUOUS / F_CONTIGUOUS
np.array(data, order="C") # row-major (default) — rows are contiguous in memory
np.array(data, order="F") # column-major — columns are contiguousWhy layout matters
Operations along the contiguous axis (rows for C-order) are faster than across it, because sequential memory access is cache-friendly. Usually irrelevant for small arrays, but matters for large-scale numerical code.
dtype Sizing
arr.astype("float32") # half the memory of float64, often sufficient precision for ML
arr.astype("int16") # if values fit in range [-32768, 32767]
arr.nbytes # check actual memory footprint| dtype | Bytes/element | Typical use |
|---|---|---|
float64 | 8 | default, full precision |
float32 | 4 | ML/GPU workloads, large arrays |
int64 | 8 | default integer |
int32 | 4 | IDs, counts within range |
bool | 1 | flags/masks |
Avoiding Unnecessary Copies
arr.reshape(...) # view when possible — cheap
arr.ravel() # view when possible — cheap
arr.flatten() # ALWAYS a copy — more expensive
arr[condition] # boolean indexing — ALWAYS a copy
arr.copy() # explicit copy — use only when truly neededCheck
.baseto see if an array owns its data
arr.base is Nonemeansarrowns its memory; otherwisearr.basepoints to the original array it’s a view into.
Pre-Allocating Output Arrays
result = np.empty_like(arr) # pre-allocate memory once
np.add(a, b, out=result) # write directly into it, avoids repeated allocation in loopsAvoiding np.append() in Loops
# SLOW — reallocates the entire array on every call
result = np.array([])
for x in data:
result = np.append(result, x * 2)
# FAST — build a list, convert once at the end
result = []
for x in data:
result.append(x * 2)
result = np.array(result)
# FASTEST — fully vectorized
result = data * 2
np.append()is not likelist.append()Every call to
np.append()allocates a brand-new array and copies all existing data into it — O(n) per call, O(n²) total in a loop. Never call it repeatedly inside a loop.
Timing & Profiling
%timeit arr ** 2 # Jupyter/IPython magic
import time
start = time.perf_counter()
result = arr ** 2
print(time.perf_counter() - start)When NumPy Isn’t Enough
Scaling beyond NumPy
For arrays too large for RAM or requiring multi-core/GPU parallelism, consider Dask (parallel, chunked, NumPy-like API), CuPy (GPU-accelerated, drop-in NumPy replacement), or Numba (
@njitJIT-compiles Python loops directly, useful when logic can’t be vectorized).