Skip to content

    Chapter 4, Environment

    Why NumPy is fast

    The answer is memory. A Python list holds pointers to scattered objects. A NumPy array holds numbers side by side, in a continuous block.

    Code for this chapter: ambiente/cap04_por_que_rapido.py

    Each element of a list is an object

    In Python, 7 is not just the number seven. It is a whole object, with a header, reference count, and type. The list holds a pointer to each of these objects, and each object resides in a different place in memory. This allows mixing types in the same list, at the cost of space and speed. The array renounces this flexibility on purpose:

    ambiente/cap04_por_que_rapido.pylinhas 10 a 21
    import sys
    
    import numpy as np
    
    lista = list(range(1_000))
    array = np.arange(1_000)
    
    print(type(lista[0]).__name__, "ocupa", sys.getsizeof(lista[0]), "bytes cada")
    print(array.dtype, "ocupa", array.itemsize, "bytes por elemento")
    
    bytes_lista = sys.getsizeof(lista) + sum(sys.getsizeof(x) for x in lista)
    print("a lista gasta mais de 3 vezes a memória do array:", bytes_lista > 3 * array.nbytes)
    
    Output
    int ocupa 28 bytes cada
    int64 ocupa 8 bytes por elemento
    a lista gasta mais de 3 vezes a memória do array: True
    

    Each Python integer occupies 28 bytes, while each element of the array occupies 8. And the array has no pointers: the thousand numbers are in a continuous block of 8 thousand bytes.

    One type, one block

    Since all elements have the same type and size, NumPy does not need to check the type of each one or follow pointers. The processor reads memory sequentially, which takes advantage of the cache, and can operate on several numbers per instruction. It is the combination of "same type, continuous block" that allows delivering the entire operation to compiled code.

    The price is that the array does not accept mixing. If you try, NumPy does not complain: it silently converts everything to a common type:

    ambiente/cap04_por_que_rapido.pylinhas 26 a 27
    misturado = np.array([7, "a", 3.2, 9])
    print(misturado.dtype, misturado)
    
    Output
    <U32 ['7' 'a' '3.2' '9']
    

    The <U32 means "Unicode text of up to 32 characters". The numbers turned into text, and you could no longer sum them. This is a classic error: it makes no noise, and only appears later, when a calculation goes wrong.

    The continuous block is also visible in the properties of the array:

    ambiente/cap04_por_que_rapido.pylinha 29
    print(array.flags["C_CONTIGUOUS"], array.strides)
    
    Output
    True (8,)
    

    The strides indicates how many bytes NumPy advances to reach the next element: 8, exactly the size of a int64. Chapter 29 explains this detail, which is the basis of how NumPy slices arrays without copying anything.

    Measure, instead of believing

    The speed difference is large enough for a simple measurement to show. I repeat each measurement a few times and keep the best, which is the least affected by machine noise. Since absolute times vary from computer to computer, what I show is the comparison:

    ambiente/cap04_por_que_rapido.pylinhas 34 a 51
    import time
    
    
    def medir(funcao, repeticoes=5):
        melhor = float("inf")
        for _ in range(repeticoes):
            inicio = time.perf_counter()
            funcao()
            melhor = min(melhor, time.perf_counter() - inicio)
        return melhor
    
    
    grande_lista = list(range(1_000_000))
    grande_array = np.arange(1_000_000)
    t_lista = medir(lambda: [x * 2 for x in grande_lista])
    t_array = medir(lambda: grande_array * 2)
    print("o array foi mais rápido:", t_array < t_lista)
    print("pelo menos 10 vezes mais rápido:", t_lista > 10 * t_array)
    
    Output
    o array foi mais rápido: True
    pelo menos 10 vezes mais rápido: True
    

    In practice, the difference usually ends up being in the tens of times. And it grows with size: with few elements, the fixed cost of calling NumPy dominates and the gain disappears. Chapter 18 returns to this point.

    Python ListNumPy Array
    TypesAny mixOne only, fixed
    MemoryPointers to scattered objectsA continuous block
    Bulk operationInterpreter loopCompiled code
    ResizeEasy (append)Expensive (creates another array)
    When to useFew items, varied typesMany numbers of the same type

    What "vectorizing" means

    Operating on the entire array at once, without writing the loop, is what we call vectorization. The grande_array * 2 in the example above is that: the loop exists, but runs in compiled code. Chapter 12 covers this in depth.

    Exercise 1

    How much does an array occupy?

    Write bytes_do_array(n, dtype) that returns how many bytes an array of n elements of a type occupies, using np.dtype(...).itemsize.