Optimizing Memory Usage with NumPy Arrays



Image by Wesley Tingey | Unsplash

 

Memory optimization is very important when working on a data science and machine learning project. Before digging deeper into this article, let’s build muscle memory by first understanding what memory optimization means and how we can effectively use NumPy for this task.

Managing and effectively distributing the computer memory resources so as to minimize memory usage while making sure that the computer system performance is at its peak is known as memory optimization.

When writing code, you need to use the appropriate data structures to maximize memory efficiency.This is because some data types consume less memory, and some consume more. You must also consider memory duplication and make sure to avoid it at all cost while freeing unused memory regularly.

NumPy is very efficient in memory unlike Python lists. NumPy stores data in a with a contiguous memory block while Python lists stores element as separate objects.

NumPy arrays have fixed data types, meaning all elements occupy the same amount of memory. This further reduces memory usage compared to Python lists, where each element’s size can vary. This makes NumPy much more memory-efficient when handling large datasets.

 

How NumPy Arrays Store Data in Contiguous Blocks of Memory

 
NumPy arrays store their elements in contiguous (adjacent) blocks of memory, meaning that all the components are packed tightly together. This layout allows fast access and efficient operations on the array, as memory lookups are minimized.

Since NumPy arrays are homogeneous, meaning all elements have the same data type, the memory space required for each element is identical. NumPy only needs to store the size of the array, the shape (i.e., dimensions), and the data type. Then it allows a direct access to elements via their index positions without following pointers. As a result, operations on NumPy arrays are much faster and require less memory overhead compared to Python lists.

 

Memory Layout in NumPy

 
There are two memory layouts in NumPy, namely, C-order and Fortran-order.

  1. C-order, also known as row-major order: When iterating over the items in C-order, the array’s final index changes the quickest. This indicates that data is kept in memory row by row, with each row being stored in a sequential manner. In NumPy, this is the default memory layout that works well for row-wise traversal operations
  2. Column-major order, or Fortran-order: Since the first index varies the quickest in Fortran-order, items are kept column by column. When interacting with systems that employ array storage in the Fortran manner or when doing numerous column-wise operations, this arrangement is helpful

The choice between C-order and Fortran-order can impact both the performance and memory access patterns of NumPy arrays.

 

Optimizing Memory Usage

 
In this section, we will cover the different methods and ways to optimize memory usage using NumPy arrays. Some of these methods include choosing the right data type, using views instead of copies, using broadcasting efficiently, reducing array size with np.squeeze and np.compress, and memory mapping with np.memmap

 

Choosing the Right Data Types

Choosing the right data type (dtype) for your NumPy arrays is one of the main strategies to minimize memory utilization. The data type you select will determine the memory footprint of an array, since NumPy arrays are homogeneous, meaning that every element in an array has the same dtype. You can save memory by utilizing smaller data types that are still within the range of your data.

import numpy as np
# Using default float64 (8 bytes per element)
array_float64 = np.array([1.5, 2.5, 3.5], dtype=np.float64)
print(f"Memory size of float64: array_float64.nbytes bytes")

# Using float32 (4 bytes per element)
array_float32 = np.array([1.5, 2.5, 3.5], dtype=np.float32)
print(f"Memory size of float32: array_float32.nbytes bytes")

# Using int8 (1 byte per element)
array_int8 = np.array([1, 2, 3], dtype=np.int8)
print(f"Memory size of int8: array_int8.nbytes bytes")

 

Code explanation:

  • The float64 dtype consumes 8 bytes (64 bits) per element, which is double the memory consumption of float32 (4 bytes)
  • The int8 dtype uses just 1 byte per element. This is ideal when dealing with small integer values that fit within this range, reducing memory consumption significantly

 

Using Views Instead of Copies

A view in NumPy refers to a new array object that refers to the same data as the original array. This saves memory because no new data is created. On the other hand, a copy is a new array object with its own separate copy of the data. Modifying a copy will not affect the original array, as it occupies its own memory space.

# Original array
original_array = np.array([1, 2, 3, 4, 5])

# Creating a view (shares the same memory as the original array)
view_array = original_array[1:4]
view_array[0] = 10  # Modifies original_array as well

# Creating a copy (allocates new memory)
copy_array = original_array[1:4].copy()
copy_array[0] = 20  # Does not modify original_array

 

Code explanation:

  • When you modify view_array, the change is reflected in original_array, as they share the same memory
  • However, modifying copy_array doesn’t affect original_array because a copy creates a completely new array in memory, leading to higher memory usage

 

Efficient Use of Broadcasting

Broadcasting in NumPy is a powerful feature that allows arrays of different shapes to be used in arithmetic operations without explicitly reshaping them. It will enable NumPy to perform operations on arrays of different shapes without creating large temporary arrays, which saves memory by reusing existing data during operations instead of expanding arrays.

Broadcasting works basically by automatically expanding smaller arrays along their dimensions to match the shape of larger arrays in an operation. This eliminates the need to manually reshape arrays or create unnecessary temporary arrays, saving memory.

# Arrays of different shapes
array = np.array([1, 2, 3])
scalar = 2

# Broadcasting scalar to perform multiplication
result = array * scalar
print(result)  # Output: [2, 4, 6]

 

Code explanation:

  • In this example, the scalar 2 is broadcasted so as to match the shape of the array, and the operation is performed without allocating extra memory for a new array
  • Broadcasting is efficient because it avoids creating temporary arrays that could increase memory usage

 

Reducing Array Size with np.squeeze and np.compress

NumPy has operations such as np.squeeze and np.compress, which help minimize array sizes by eliminating unnecessary dimensions or filtering certain data.

# Array with unnecessary dimensions
array_with_extra_dims = np.array([[[1], [2], [3]]])

# Remove the extra dimensions
squeezed_array = np.squeeze(array_with_extra_dims)
print(squeezed_array.shape)  # Output: (3,)

# Original array
data = np.array([1, 2, 3, 4, 5])

# Use np.compress to filter data
filtered_data = np.compress([0, 1, 0, 1, 0], data)
print(filtered_data)  # Output: [2, 4]

 

Code explanation:

  • np.squeeze removes dimensions of size 1, which simplifies the shape of the array and saves memory by reducing complexity in memory allocation
  • np.compress filters an array based on a condition, creating a smaller array that reduces memory usage by discarding unnecessary elements

 

Memory Mapping with np.memmap

Memory mapping (np.memmap) allows you to work with large datasets that don’t fit into memory by storing data on disk and accessing only the necessary portions.

# Create a large array on disk using memory mapping
data = np.memmap('large_data.dat', dtype=np.float32, mode="w+", shape=(1000000,))

# Modify a portion of the array in memory
data[5000:5010] = np.arange(10)

# Flush changes back to disk
data.flush()

 

Code explanation:

  • np.memmap creates a memory-mapped array that accesses data from disk rather than storing it all in memory. You will find this useful when handling datasets that exceed your system’s memory limits
  • You can modify portions of the array, and the changes are written back to the file on disk, saving memory

 

Conclusion

 
In conclusion, in this article we have been able to learn how to optimize memory usage using NumPy arrays. If you are conveniently leverage the methods highlighted in this article such as choosing the right data types, using views instead of copies, and taking advantage of broadcasting, you can significantly reduce memory consumption without sacrificing performance.
 
 

Shittu Olumide is a software engineer and technical writer passionate about leveraging cutting-edge technologies to craft compelling narratives, with a keen eye for detail and a knack for simplifying complex concepts. You can also find Shittu on Twitter.



Recent Articles

Related Stories

Leave A Reply

Please enter your comment!
Please enter your name here