DEV Community

Cover image for Python performance: native loops, NumPy and the vectorize trap

Python performance: native loops, NumPy and the vectorize trap

Python performance discussions tend to reach for a language migration before measuring the work. A numerical loop makes the problem easier to see: you can keep the calculation unchanged, move its repeated execution into a native operation, and finish much sooner on the same CPU. You can also spend the entire gain converting the data around that operation.

We tested both situations, plus a compiled Python-syntax loop and the tempting np.vectorize wrapper. The result gives you a practical question to ask before rewriting anything: where does the loop actually run, and which input and output does your caller need?

TL;DR

  • Numeric NumPy operations can process typed arrays through compiled implementations, reducing repeated interpreter and object work.
  • The ready-array case was dramatically faster in our workload. A Python-list input/output contract removed that advantage.
  • Small inputs favored a comprehension. A warmed Numba loop was fast too; its initial compilation had a separate cost.
  • np.vectorize provides a convenient array-facing wrapper around a Python function. Its documented implementation still calls that function across elements.
  • Profile the complete path and retain the result representation that the next operation can use.

What this Python performance test measures

The calculation was y = x*x + x. Every implementation received the same numerical values, and every checked answer matched exactly. Inputs were bounded, integer-valued floating-point numbers, making this expression's rounding behavior straightforward. This was an elementwise calculation, with no reduction whose summation order could change the answer.

The primary run used this environment:

Component Tested configuration
Interpreter CPython 3.10.12
Array library NumPy 2.2.6
Compiler Numba 0.67.0
Machine Intel Core i9-13900HX, WSL2
Execution One logical CPU affinity; no GPU backend

We retained eleven warmed timing trials per method and case, shuffled method order, and recorded output correctness and the environment. All forty correctness comparisons passed.

There were two contracts. Ready inputs prepared a Python list or numeric array before the timer and allowed the implementation's normal output representation. Python-list input/output began with a Python list and required a fresh Python list at the end. The numerical backends therefore included conversion into an array and conversion back out.

For one million values, these were the primary run's median milliseconds:

Implementation Ready inputs Python-list input/output
Explicit Python loop 43.326 36.698
Comprehension 32.538 27.613
NumPy numeric expression 0.917 27.848
Python callback through np.vectorize 86.829 105.721
Warmed Numba loop 0.379 27.566

The ready-array gain is substantial. The common-list comparison is a near tie between the comprehension and the numerical implementations. Those are answers to different caller requirements.

These are shared-workstation observations. Frequencies were not isolated, and the recorded ranges show noise. Garbage collection was disabled during timing and restored afterward; allocation and release of results remained inside the timed calls. Imports, input preparation and initial JIT compilation were outside the warmed measurements. Cross-scope changes in the same comprehension should not be interpreted as a causal optimization.

The work surrounding an ordinary Python loop

CPython compiles source into bytecode and executes those instructions through its interpreter. Its own implementation is native code. The relevant difference here is the work repeated around each numerical value.

The actual tested function was:

def python_loop(values):
    result = []
    for value in values:
        result.append(value * value + value)
    return result
Enter fullscreen mode Exit fullscreen mode

Its saved CPython 3.10 disassembly includes iteration, multiplication and addition instructions. The version-specific bytecode documentation is important: newer interpreters can specialize or execute this work differently.

A Python list stores references to objects. The CPython list declaration exposes that arrangement as PyObject **ob_item. General Python operations support a much wider range of objects than this repetitive arithmetic needs. Our input happened to contain only floats; we measured no heterogeneous-data workload.

A comprehension improves the expression of this particular job and was faster than the explicit loop in the recorded large-input cases. Replacing the spelling of a loop does not automatically remove its per-element execution path.

How NumPy vectorization changes the contract

A numeric ndarray carries a declared type and a layout that its implementation understands. The NumPy array documentation describes a container of items with the same type and size, and explains strides and views. Our arrays were contiguous float64 arrays. Other layouts are supported, so contiguous storage is a property of this test rather than a promise about every ndarray.

The tested batch function was short:

import numpy as np

def numpy_batch(values):
    return values * values + values
Enter fullscreen mode Exit fullscreen mode

For this numeric array, the arithmetic hands whole batches to native operations. The ufunc documentation notes that many built-in functions have compiled C implementations. The loop continues to exist inside that implementation, where known numeric types permit a different execution path.

Keep three concepts separate: a batch interface, a native implementation and processor vector instructions. Itamar Turner-Trauring's explanation of vectorization's limits makes that distinction explicit. A function accepting an array gives you little evidence about the code that handles each element.

This expression also performs two operations and creates a temporary product before producing the final output. One attractive line of Python can request multiple traversals and buffers. It is worth looking at supported output-buffer reuse or compilation when temporary arrays become the expensive part.

Conversion and small inputs can reverse the result

At eight values, the primary run's ready-input comprehension beat the numeric NumPy expression. Library dispatch and setup were a larger part of the tiny task. The larger ready arrays pulled ahead in the measured size sweep.

Then the caller contract changed. The numerical path had to turn a Python list into an array, compute the expression, and turn the output into Python objects again. The arithmetic stayed fast. The complete path acquired work on both sides of it.

We independently repeated the identical source on CPython 3.13.2, with the same NumPy and Numba versions. Another forty exact checks passed. The small-input comprehension win, ready-array advantage and callback penalty persisted. With one million values under the common-list contract, the comprehension took about 23 milliseconds while NumPy took about 33. In that observation the numerical path lost.

The two separately timed runs do not isolate the causal effect of a Python version change. They reinforce a more useful boundary: conversion can consume the benefit, and a universal speed multiplier would misdescribe the evidence. Neither run claims to measure the latest Python release.

Follow the next operation. If it accepts an array, keeping that representation avoids a needless round trip. If an external interface truly requires Python objects, its conversion cost belongs in your measurement. Input preparation, parsing and network waits may require their own profiling too.

The np.vectorize callback trap

Here is the function and wrapper from the actual test:

def scalar(value):
    return value * value + value

vectorized_callback = np.vectorize(scalar, otypes=[np.float64])
Enter fullscreen mode Exit fullscreen mode

The name is easy to mistake for an execution guarantee. NumPy's vectorize documentation describes the wrapper as primarily a convenience feature and states: “The implementation is essentially a for loop.”

The wrapper offers broadcasting behavior while repeatedly invoking the supplied Python function. Our million-value ready-input callback case took about 87 milliseconds, slower than the comprehension. Returning through the callback preserved the kind of per-element work we wanted to reduce. The stopwatch never received the memo about that impressive function name.

Convenience can still be the right objective. Keep the wrapper when its interface solves your problem and its runtime is acceptable. For a performance objective, inspect the implementation and measure the actual function call path.

A compiled Python loop can be fast

Numba's performance guide explicitly demonstrates loops. Our tested numerical function retained loop syntax:

from numba import njit

@njit(fastmath=False, parallel=False)
def compiled_loop(values):
    result = np.empty_like(values)
    for index in range(len(values)):
        value = values[index]
        result[index] = value * value + value
    return result
Enter fullscreen mode Exit fullscreen mode

Numba compiled this supported function for the array types it received. The primary warmed result was below half a millisecond with ready arrays. Initial compilation plus execution took about a second and was recorded separately.

Those two clocks serve different questions. A long-running process that repeatedly calls a supported kernel can amortize compilation. A tiny one-off task may spend most of its time setting up. Our warmed number does not establish the best choice for cold scripts or arbitrary Python objects.

You can combine the Python snippets above and check them with the following input. We executed this check against the article's actual snippets. It verifies answers; the earlier performance protocol additionally records warmed repeated timings and the caller contract.

values = [float((index % 2001) - 1000) for index in range(4096)]
array = np.asarray(values, dtype=np.float64)
expected = np.asarray(python_loop(values), dtype=np.float64)
outputs = (
    numpy_batch(array),
    vectorized_callback(array),
    compiled_loop(array),
)
for output in outputs:
    np.testing.assert_array_equal(output, expected)
Enter fullscreen mode Exit fullscreen mode

A profiling-first checklist

Locate the operation that consumes the time. Establish its real input and required output. Then compare candidates under that contract.

For numerical hot paths, check whether the data already fits a numeric array, whether subsequent operations can retain it, and whether temporary buffers dominate. If supported Python-syntax loops can compile into a useful kernel, test that alternative with both cold and warmed costs visible.

For callback wrappers, inspect what runs once per element. For parsing or network waits, measure those stages directly. Changing a numerical loop can have little effect on a program dominated by a different stage.

Keep repeated timings and verify outputs. Record the interpreter and library versions so somebody else can reproduce the comparison. A saved fast result is useful when you know the question its timer answered. Starting a language migration before that would be an expensive way to avoid opening a profiler.

Verdict: SHIP IT

I'd ship batching for a measured numerical hot path when the data and caller contract support it. Ordinary loops can remain the clearest implementation when they already perform adequately. The mechanism worth learning is the execution boundary, together with the cost of crossing data representations.

FAQ

Does normal NumPy arithmetic use my GPU?

This test used NumPy CPU ndarrays. A backend such as CuPy provides GPU arrays and operations, with its own memory and transfer requirements. We performed no GPU timing comparison.

Does native execution guarantee SIMD or multiple cores?

No. NumPy's CPU/SIMD machinery can select supported implementations, but feature availability does not identify the instructions used by a particular expression. We took no hardware-counter trace and assigned no fraction of the gain to SIMD.

Is a comprehension always the fastest ordinary Python spelling?

Our comprehension won over the explicit loop in the recorded large-input cases. Different work, versions and implementations can produce different results. Measure the operation you actually need.

Should I replace every loop with np.vectorize?

Use it for appropriate convenience and broadcasting requirements. Its callback execution means that a shorter array-facing interface can retain Python work for every element. A numeric ufunc or supported compiled loop is a different implementation choice.

Sources

The local experiments and their raw trials are preserved in the production evidence. The tables report our measurements, independently of the illustrative benchmarks in these sources.


This article expands on an episode of **The Daily Diff, a five-minute daily video on what shipped and what broke in tech.
Watch the episode · Subscribe on YouTube · the written diff lands in your inbox every morning at thedailydiff.dev.

Video transcript and primary sources

Top comments (0)