Where the time and the bytes actually go, and the handful of changes that move the needle.
Overview
The order to try things
Most NumPy performance work comes down to four questions, roughly in order of payoff:
Is there still a Python loop? Removing it is worth 10–100x.
Is the dtype larger than it needs to be? Halving it is worth up to 2x, and halves memory.
Are temporaries being allocated in a hot path? In-place operations remove them.
Is the memory access pattern fighting the cache? Layout changes can be worth 2–5x.
The first is covered in the introductory module and dwarfs the rest. This module is about the other three, which matter once the obvious loop is gone.
Worth knowing
Choosing float32 over float64 halves memory and usually speeds things up, because less data has to move.
Every intermediate expression allocates a full-size temporary array. a * 2 + 1 allocates twice.
In-place operators (*=, +=) and out= reuse memory you already have.
C order makes the last axis contiguous. Reading strided memory (copying a transpose) is several times slower than reading consecutive bytes.
Preallocate with np.empty and assign, rather than growing an array — np.append in a loop is quadratic.
Measure with nbytes and a timing loop, and take the best of several runs rather than the average.
Performance and Memory
Where the time and the bytes actually go.
dtype is the cheapest saving there is
Half the width, half the memory, and usually faster because less has to travel.
example_01.pyNumPy
Output
Temporaries are the hidden cost
Every intermediate expression allocates a full-size array you never see.
example_02.pyNumPy
Output
out= writes into memory you already have
The explicit form of the same idea, and it works with any ufunc.
example_03.pyNumPy
Output
Strided access costs more than contiguous access
Same number of bytes, same result - only the order they are read in differs.
example_04.pyNumPy
Output
Preallocate instead of growing
The same lesson as concatenating in a loop, stated as a habit.
example_05.pyNumPy
Output
Measuring rather than guessing
nbytes for size, and a timing loop for anything you are about to optimise.
example_06.pyNumPy
Output
dtype
float64 is the default and is often more precision than the data justifies. Sensor readings good to three significant figures do not benefit from fifteen.
float32 keeps about seven significant digits, halves the memory, and typically runs faster — not because the arithmetic is cheaper, but because half as much data has to travel from memory to the processor, and memory bandwidth is usually the limit.
The same applies to integers. An array of small counts does not need int64. An array of flags should be bool_, which is one byte, not int64, which is eight.
The caution from the dtypes module still applies: narrow integers overflow silently. Choose the smallest type that cannot overflow for your actual data, not the smallest that happens to fit today's sample.
Temporaries
a * 2 + 1 on a million-element array does not do one pass. It allocates an 8 MB temporary for a * 2, then allocates another 8 MB for the final result, then frees the first.
You never see them, and for a single expression it rarely matters. In a loop, or in a chain of five operations on a large array, the allocation and the extra memory traffic dominate.
Two ways to avoid it.
In-place operators.a *= 2 modifies the existing buffer and allocates nothing. Remember that it also modifies anything sharing that buffer, which is the views-versus-copies problem again.
out=. Every ufunc accepts out, which names the destination explicitly. np.multiply(a, 3, out=dest) writes into dest, and out may be one of the inputs. This is the clearest form when you are accumulating into a buffer across iterations.
Neither is worth doing everywhere. a * 2 + 1 is more readable than two statements, and readability wins until you have measured that this line matters.
Layout and the cache
A C-ordered array stores the last axis contiguously. Consecutive elements along that axis sit next to each other in memory.
Processors fetch memory in cache lines, so reading consecutive bytes is far cheaper than jumping a row at a time.
Reductions are a poor demonstration of this. NumPy blocks them internally, so sum(axis=0) and sum(axis=1) usually run at similar speed and sometimes in the order you would not predict. It is worth knowing that, because the "always reduce along the last axis" advice is repeated widely and does not survive measurement.
Where it shows clearly is when data is actually moved. Copying a (1200, 1200) array and copying its transpose move the same number of bytes and produce the same number of bytes, but the transpose reads one value from each row in turn, and runs several times slower.
Two practical consequences. If you have a choice about which axis holds the thing you iterate over, put it last. And if you are about to make many strided passes over the same data, one np.ascontiguousarray up front can pay for itself — a single copy against many slow reads.
Preallocation
np.append and np.concatenate do not append. They allocate a new array and copy everything. In a loop that is quadratic, and it is the single most common accidental slowdown in NumPy code after the plain Python loop.
If you know the final size, np.empty(n) and assignment by index is the direct answer. np.empty does not initialise, so it is faster than np.zeros when you are going to overwrite everything — and dangerous if you are not, because the contents are whatever was in that memory.
If you do not know the size, collect into a Python list and convert once at the end. Lists are designed for growth; arrays are not.
Measuring
Guessing is unreliable, and the intuitions transferred from pure Python are often wrong.
a.nbytes gives the real size of the data. a.itemsize gives the per-element cost.
For time, run the operation several times and take the minimum, not the mean. The slow runs are measuring interference from the rest of the machine; the fastest run is the closest estimate of the actual cost. A single timed run of a fast operation measures mostly noise.
And measure the thing you intend to change. Optimising an operation that accounts for 2% of the runtime is effort spent for a 2% ceiling, however satisfying the local speedup looks.
Views and memory that will not go away
A view holds a reference to the array it came from, so the entire base buffer stays alive as long as the view does.
Extract one row from an 8 MB array, keep it, and you are keeping 8 MB.
This is the most common source of NumPy memory that never comes back, and it is invisible — row.nbytes reports four kilobytes while the process holds eight megabytes.
The fix is copy() at the point you decide to keep something small from something large. The cost is one small allocation; the saving is the whole base.
a.base shows what a view derives from, and a.base.nbytes shows what is actually being held. That is the diagnostic when memory in a long-running process grows without an obvious cause.
Threads and the GIL
NumPy releases the GIL during most array operations, which means threads genuinely run in parallel inside a NumPy computation — unlike most pure Python code.
That makes concurrent.futures.ThreadPoolExecutor a real option for parallelising array work, with none of the pickling and process-startup costs of multiprocessing.
Two caveats. Operations that touch Python objects — object arrays, anything calling back into Python — do not release it. And BLAS routines are usually already multi-threaded internally, so adding your own threads on top can oversubscribe the machine and run slower. OMP_NUM_THREADS and threadpoolctl control that layer.
Measure before assuming threading helps. For large matrix operations it often does nothing, because BLAS was already using every core.
Chunking
When an operation would allocate more than fits in memory, the answer is usually to do it in pieces.
The classic case is a pairwise distance matrix. For 100,000 points, the intermediate is 10 billion entries and no machine will hold it. Processing 1,000 rows at a time keeps the intermediate at 100 million and gives the same answer.
The general shape:
for start in range(0, n, chunk):
block = data[start:start + chunk]
out[start:start + chunk] = expensive(block)
The slices are views, so the chunking itself allocates nothing, and the output is preallocated once.
This is one of the cases where a loop is correct. The loop runs n / chunk times, not n times, so the interpreter overhead is negligible and the memory ceiling is the thing being controlled.
When to reach past NumPy
NumPy has a ceiling, and recognising it saves effort spent optimising against it.
Numba compiles a Python function to machine code with a decorator. It is the right answer for genuinely sequential algorithms — the ones that cannot be vectorised — and often gets within range of C for a few lines of change.
Cython gives more control at the cost of a build step, and is what several scientific libraries use internally.
SciPy already contains a compiled implementation of a great many things people write by hand: distance matrices, sparse matrices, signal filters, optimisation, interpolation. Checking whether SciPy has it is cheaper than writing it.
Dask handles arrays larger than memory with a NumPy-like interface, splitting work into chunks automatically.
CuPy / PyTorch move the work to a GPU, with an interface close enough to NumPy that porting is often mechanical. Worth it for large array workloads, not worth it for small ones where the transfer cost dominates.
The order to consider them: is it vectorisable in NumPy, is it in SciPy, is it sequential and worth Numba, is it too big for memory, is it big enough for a GPU.
Profiling before optimising
The instinct about where time goes is unreliable, and array code is no exception.
cProfile gives function-level timings and finds the hot function. line_profiler gives per-line timings within it, which is what you actually need when one function contains the whole computation.
memory_profiler tracks allocation line by line, and a.nbytes summed over the arrays you are holding is a quick manual version.
Two rules make profiling worth the time.
Profile the real workload. A toy input can have completely different characteristics — different cache behaviour, different branch of the algorithm, different memory pressure.
Fix the top item, then measure again. The bottleneck moves. Optimising the second item on the original list is often wasted, because after the first fix it is no longer second.
And keep the ceiling in view: a function taking 5% of runtime cannot give more than a 5% improvement however completely you optimise it.
Where the wins actually are, in order
Remove the Python loop. 10–100x, and it dominates everything else. If a loop over elements is still there, nothing below this line matters yet.
Avoid quadratic growth.np.append or concatenate in a loop turns a linear job into a quadratic one. The fix is preallocation or a list.
Right-size the dtype. Up to 2x on bandwidth-bound work, and it halves memory.
Remove temporaries in hot paths. In-place operators and out=. Worth doing where measurement says it matters, not everywhere.
Fix access patterns. Contiguity, and avoiding repeated strided gathers.
Reach past NumPy. Numba, SciPy, a GPU — when the algorithm genuinely does not vectorise or the data genuinely does not fit.
Working down that list in order means the large wins come first, and it avoids the common trap of micro-optimising an expression inside a loop that should not exist.
Things that look slow and are not
Slicing. A view. Free, whatever the array size.
Transposing. A view. Free.
Reshaping a contiguous array. A view. Free.
Reversing with [::-1]. A view. Free.
Broadcasting the inputs. No allocation; the strides are set to zero. The *output* is allocated, which is a different thing.
Rewriting any of these to "avoid the copy" is effort spent on a copy that was never happening. Confirm with np.shares_memory before optimising something that may already be free.
Things that look cheap and are not
Boolean masking and fancy indexing. Both allocate, and both gather from scattered positions.
astype. Allocates a full second array, even when the dtype is unchanged, unless copy=False.
Chained expressions on large arrays. Every intermediate is a full-size allocation.
np.append. Not an append. A full copy, every call.
Keeping a small view of a large array. Holds the whole base alive.
The discipline
Measure before optimising, and profile the real workload rather than a small stand-in — cache behaviour and memory pressure do not scale down predictably.
Take the minimum of several timed runs, not the mean.
Fix the top item and measure again, because the bottleneck moves.
Keep the ceiling in view: an operation taking 5% of the runtime cannot yield more than 5%, however thoroughly it is optimised.
And weigh readability honestly. A vectorised one-liner that nobody can safely modify has a maintenance cost that does not show up in a benchmark, and the version a colleague can read is often the better engineering answer even when it is slower.
Check yourself
0 of 4
Answer without scrolling back up.
Why is `float32` often faster than `float64`, not just smaller?
Most array operations are bandwidth-bound rather than compute-bound, so halving the bytes roughly halves the time.
How many temporary arrays does `a * 2 + 1` allocate?
On a million float64 elements that is 8 MB you never see. In-place operators or `out=` remove them - but only bother once you have measured that the line matters.
Copying an array and copying its transpose move the same bytes. Why is the transpose slower?
Processors fetch cache lines, so consecutive reads are far cheaper than jumping. Reductions are a poor test of this - NumPy blocks them and both directions run at similar speed.
When timing an operation, should you take the mean or the minimum of several runs?
Slow runs measure interference from the rest of the machine. The fastest run is the closest estimate of the actual cost.
Cheat sheet
Performance and Memory
The first is covered in the introductory module and dwarfs the rest. This module is about the other three, which matter once the obvious loop is gone.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.