Parallel, larger-than-memory, n-dimensional array using blocked algorithms.
In other words, Dask Array implements a subset of the NumPy ndarray interface using blocked algorithms, cutting up the large array into many small arrays. This lets us compute on arrays larger than memory using all of our cores. We coordinate these blocked algorithms using Dask graphs.
In this notebook, we'll build some understanding by implementing some blocked algorithms from scratch. We'll then use Dask Array to analyze large datasets, in parallel, using a familiar NumPy-like API.
Related Documentation
Create the datasets you will be using in this notebook:
%run prep.py -d random
from dask.distributed import Client
client = Client(n_workers=4)
client
Let's do side by side the sum of the elements of an array using a NumPy array and a Dask array.
import numpy as np
import dask.array as da
# NumPy array
a_np = np.ones(10)
a_np
We know that we can use sum()
to compute the sum of the elements of our array, but to show what a blocksized operation would look like, let's do:
a_np_sum = a_np[:5].sum() + a_np[5:].sum()
a_np_sum
Now notice that each sum in the computation above is completely independent so they could be done in parallel.
To do this with Dask array, we need to define our "slices", we do this by defining the amount of elements we want per block using the variable chunks
.
a_da = da.ones(10, chunks=5)
a_da
Important!
Note here that to get two blocks, we specify chunks=5
, in other words, we have 5 elements per block.
a_da_sum = a_da.sum()
a_da_sum
In general, the code that humans write rely on compilers or interpreters so the computers can understand what we wrote. When we move to parallel execution there is a desire to shift responsibility from the compilers to the human, as they often bring the analysis, optimization, and execution of code into the code itself. In these cases, we often represent the structure of our program explicitly as data within the program itself.
In Dask we use task scheduling, where we break our program into into many medium-sized tasks or units of computation.We represent these tasks as nodes in a graph with edges between nodes if one task depends on data produced by another. We call upon a task scheduler to execute this graph in a way that respects these data dependencies and leverages parallelism where possible, so multiple independent tasks can be run simultaneously.
# visualize the low level Dask graph using cytoscape
a_da_sum.visualize(engine="cytoscape")
a_da_sum.compute()
Let's try a more interesting example. We will create a 20_000 x 20_000 array with normally distributed values, and take the mean along one of its axis.
Note:
If you are running on Binder, the Numpy example might need to be a smaller one due to memory issues.
%%time
xn = np.random.normal(10, 0.1, size=(30_000, 30_000))
yn = xn.mean(axis=0)
yn
xd = da.random.normal(10, 0.1, size=(30_000, 30_000), chunks=(3000, 3000))
xd
xd.nbytes / 1e9 # Gigabytes of the input processed lazily
yd = xd.mean(axis=0)
yd
%%time
xd = da.random.normal(10, 0.1, size=(30_000, 30_000), chunks=(3000, 3000))
yd = xd.mean(axis=0)
yd.compute()
Questions to think about:
Exercise:
For Dask arrays, compute the mean along axis=1
of the sum of the x array and its transpose.
# Your code here
Solution
x_sum = xd + xd.T
res = x_sum.mean(axis=1)
res.compute()
This section was inspired on a Dask blog by Genevieve Buckley you can read it here
A common problem when getting started with Dask array is determine what is a good chunk size. But what is a good size, and how do we determine this?
We can think of Dask arrays as a big structure composed by chunks of a smaller size, where these chunks are typically an a single numpy
array, and they are all arranged to form a larger Dask array.
If you have a Dask array and want to know more information about chunks and their size, you can use the chunksize
and chunks
attributes to access this information. If you are in a jupyter notebook
you can also visualize the Dask array via its HTML representation.
darr = da.random.random((1000, 1000, 1000))
darr
Notice that when we created the Dask array, we did not specify the chunks
. Dask has set by default chunks='auto'
which accommodates ideal chunk sizes. To learn more on how auto-chunking works you can go to this documentation https://docs.dask.org/en/stable/array-chunks.html#automatic-chunking
darr.chunksize
shows the largest chunk size. If you expect your array to have uniform chunk sizes this is a a good summary of the chunk size information. But if your array have irregular chunks, darr.chunks
will show you the explicit sizes of all the chunks along all the dimensions of your dask array.
darr.chunksize
darr.chunks
Let's modify our example to see explore chunking a bit more. We can rechunk our array:
darr = darr.rechunk({0: -1, 1: 100, 2: "auto"})
darr
darr.chunksize
darr.chunks
Exercise:
If your chunks are too small, the amount of actual work done by every task is very tiny, and the overhead of coordinating all these tasks results in a very inefficient process.
In general, the dask scheduler takes approximately one millisecond to coordinate a single task. That means we want the computation time to be comparatively large, i.e in the order of seconds.
Intuitive analogy by Genevieve Buckley:
Lets imagine we are building a house. It is a pretty big job, and if there were only one worker it would take much too long to build. So we have a team of workers and a site foreman. The site foreman is equivalent to the Dask scheduler: their job is to tell the workers what tasks they need to do.
Say we have a big pile of bricks to build a wall, sitting in the corner of the building site. If the foreman (the Dask scheduler) tells workers to go and fetch a single brick at a time, then bring each one to where the wall is being built, you can see how this is going to be very slow and inefficient! The workers are spending most of their time moving between the wall and the pile of bricks. Much less time is going towards doing the actual work of mortaring bricks onto the wall.
Instead, we can do this in a smarter way. The foreman (Dask scheduler) can tell the workers to go and bring one full wheelbarrow load of bricks back each time. Now workers are spending much less time moving between the wall and the pile of bricks, and the wall will be finished much quicker.
If your chunks are too big, this is also a problem because you will likely run out of memory. You will start seeing in the dashboard that data is being spill to disk and this will lead to performance decrements.
If we load to much data into memory, Dask workers will start to spill data to disk to avoid crashing. Spilling data to disk will slow things down significantly, because of all the extra read and write operations to disk. This is definitely a situation that we want to avoid, to watch out for this you can look at the worker memory plot on the dashboard. Orange bars are a warning you are close to the limit, and gray means data is being spilled to disk.
To watch out for this, look at the worker memory plot on the Dask dashboard. Orange bars are a warning you are close to the limit, and gray means data is being spilled to disk - not good! For more tips, see the section on using the Dask dashboard below. To learn more about the memory plot, check the dashboard documentation.
For more more advice on chunking see https://docs.dask.org/en/stable/array-chunks.html
Zarr is a format for the storage of chunked, compressed, N-dimensional arrays. Zarr provides classes and functions for working with N-dimensional arrays that behave like NumPy arrays (Dask array behave like Numpy arrays) but whose data is divided into chunks and each chunk is compressed. If you are already familiar with HDF5 then Zarr arrays provide similar functionality, but with some additional flexibility.
For extra material check the Zarr tutorial
Let's read an array from zarr:
import zarr
a = da.from_zarr("data/random.zarr")
a
Notice that the array is already chunked, and we didn't specify anything when loading it. Now notice that the chunks have a nice chunk size, let's compute the mean and see how long it takes to run
%%time
a.mean().compute()
Let's load a separate example where the chunksize
is much smaller, and see what happen
b = da.from_zarr("data/random_sc.zarr")
b
%%time
b.mean().compute()
Provide a chunksize
when reading b
that will improve the time of computation of the mean. Try multiple chunks
values and see what happens.
# Your code here
# 1 possible Solution (imitate original). chunks will vary if you are in binder
c = da.from_zarr("data/random_sc.zarr", chunks=(6250000,))
c
%%time
c.mean().compute()
In some applications we have multidimensional data, and sometimes working with all this dimensions can be confusing. Xarray is an open source project and Python package that makes working with labeled multi-dimensional arrays easier.
Xarray is inspired by and borrows heavily from pandas, the popular data analysis package focused on labeled tabular data. It is particularly tailored to working with netCDF files, which were the source of xarray’s data model, and integrates tightly with Dask for parallel computing.
Xarray introduces labels in the form of dimensions, coordinates and attributes on top of raw NumPy-like arrays, which allows for a more intuitive, more concise, and less error-prone developer experience.
Let's learn how to use xarray and Dask together:
import xarray as xr
ds = xr.tutorial.open_dataset(
"air_temperature",
chunks={ # this tells xarray to open the dataset as a dask array
"lat": 25,
"lon": 25,
"time": -1,
},
)
ds
ds.air
ds.air.chunks
mean = ds.air.mean("time") # no activity on dashboard
mean # contains a dask array
# we will see dashboard activity
mean.load()
Let's grab the air variable and do some operations. Operations using xarray objects are identical, regardless if the underlying data is stored as a Dask array or a NumPy array.
dair = ds.air
dair2 = dair.groupby("time.month").mean("time")
dair_new = dair - dair2
dair_new
Call .compute()
or .load()
when you want your result as a xarray.DataArray
with data stored as NumPy arrays.
# things happen in the dashboard
dair_new.load()
Because we have a datetime index time-series operations work efficiently, for example we can do a resample and then plot the result.
dair_resample = dair.resample(time="1w").mean("time").std("time")
dair_resample.load().plot(figsize=(12, 8))
Both xarray and zarr have their own tutorials that go into greater depth:
It's good practice to close any Dask cluster you create:
client.shutdown()