Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Vectorization, Numpy Universal Functions

Open in Colab

In order to write performant code using numerical libraries, it is useful to keep the following rule in mind:

Code that is predictable can be made fast

The most common example of predictable code is a fixed length loop which performs the same operation at each iteration (up to changes in index)

Examples

  1. Matrix-vector multiplication

  2. Functions applied element-wise to an array

Non-examples:

  1. Code with branch instructions (if, else, etc.)

  2. Code with recursive function calls (at least in Python)

One reason why predictable code can be fast is that most CPUs have what is called a branch predictor in them, which pre-loads computation. If a branch is predicted incorrectly, then the CPU has to switch gears and go along the correct brach, which takes time. Code without branches will minimize the number of branch prediction errors, speeding up code.

Another reason why predictable code can be made fast is vectorization. If you are performing the same operation in a predictable way, code can employ special instructions such as AVX which can greatly increase efficiency.

You don’t need to worry about the details in Python, but it is good to know how to write code that allows libraries like NumPy to take advantage of these techniques. Note that standard Python loops will not take advantage of these things - you typically need to use libraries.

Universal Functions

Numpy universal functions (or ufuncs) are functions that are applied element-wise to an array. Examples include most math operations and logical comparisons. You can find additional information in the ufunc documentation.

time for naive add: 2.965e-01 sec
time for numpy add: 4.709e-03 sec

If you prefer to write your code without the for-loop, you can use np.vectorize. See the documentation for details.

time for naive add: 2.570e-01 sec
time for numpy add: 4.580e-03 sec
time for numpy add (vectorize): 2.545e-01 sec

np.vectorize doesn’t really give a performance boost, but can make defining functions simpler

Exercise

  1. perform some timing tests that compare a naive python loop implementation with a numpy ufunc. Try computing sin, exp, multiplication, etc.

More complicated functions

Some functions that can be sped up considerably are a bit more complicated than ufuncs. One example is matrix-vector multiplication. We’ll use the notation Ax = y, where

yi=∑jAi,jxjy_i = \sum_j A_{i,j} x_j
time for naive matvec: 4.784e-01 sec
time for numpy matvec: 6.142e-04 sec
7.19019311536421e-13

Numba

Numba is a just in time (JIT) compiler for Python code. It provides several decorators which make it very easy to get speedups for numerical code in many situations.

Just in time compilation is an increasingly popular solution that bridges the gap between interpreted and compiled languages. Generally:

  • Interpreted languages (such as Python) simply read code line-by-line and execute as they go along.

  • Compiled languages (such as C, C++, fortran) compile code into a binary, which can be optimized to run quickly

Compilation takes time intially but saves time when you use the binary. Python libraries such as NumPy and SciPy use compiled libraries under the hood for speed. Interpreted languages tend to be slower, but are easier to develop in.

Just in time compilation will produce a compiled version of a function the first time it is needed. Julia is a relatively new language which uses JIT to produce fast code with less development overhead.

One of the things you need to know to compile code is the types used - if you want to use different types (e.g. single and double precision versions of a function), you need different compiled versions. Python usually allows you to not worry too much about type, but this is one reason why you need to know about it anyways in scientific computing.

First:

$ conda install numba

Let’s look at how Numba can be used with our naive_add ufunc.

/tmp/ipykernel_5225/852113555.py:3: NumbaDeprecationWarning: The 'nopython' keyword argument was not supplied to the 'numba.jit' decorator. The implicit default value for this argument is currently False, but it will be changed to True in Numba 0.59.0. See https://numba.readthedocs.io/en/stable/reference/deprecation.html#deprecation-of-object-mode-fall-back-behaviour-when-using-jit for details.
  @jit # this is the only thing we do different
time for naive add: 2.072e-01 sec
time for numpy add: 2.060e-03 sec
time for numba add: 5.392e-01 sec

The numba JIT function runs in about the same time as the naive function. Let’s see what happens when we run the code again:

time for naive add: 2.158e-01 sec
time for numpy add: 4.374e-03 sec
time for numba add: 2.573e-03 sec

Now the numba function is much faster. This is because the first time the function is called, it must be compiled. Every subsequent time you call the function, it will run much faster.

The take-away is that it is advantageous to use JIT with functions you will use repeatedly, but not necessarily worth the time for functions you will only use once.

Advanced numba

You can get a lot of mileage out of numba without too much trouble. It is always good to look at the documentation to learn more. Here are a few examples:

Parallelization (this is supported on a handful of known operations).

time for numba add: 1.805e-01 sec
time for numpy add: 1.962e-01 sec
time for numba parallel add: 2.842e-01 sec

Parallelization of matvec:

time for numba parallel matvec: 8.006e-03 sec
time for numpy matvec: 1.781e-02 sec
8.518584735988475e-12

declaring ufuncs without a loop. You can define a function on elements using numba.vectorize. Unlike numpy.vectorize, numba will give you a noticeable speedup. We need to provide a call signature - for example float32(float32, float32) will take two different single precision floating point numbers (float32) as input, and return a single precision floating point number as output. See the documentation for additional details.

time for numba add: 3.819e-01 sec
time for numba add (vectorized): 1.390e-01 sec

For NVIDIA GPUS, you can use cuda through numba - see the Nvidia page for example

time for numba add: 3.125e-01 sec
time for numba add (vectorized): 2.114e-01 sec
time for numba add (cuda): 5.494e-01 sec

We see that there may be a bit of a speedup using GPU - this will depend on the type of operation being performed, and if the GPU will be better suited for the computation compared to a vectorized instruction on a CPU.

For more information, see performance hints