Quick Start: Basic Operations with nycflights13

ZJ

Quick Start - replicating dplyr’s tutorial on nycflight13

The disk.frame package aims to be the answer to the question: how do I manipulate structured tabular data that doesn’t fit into Random Access Memory (RAM)?

In a nutshell, disk.frame makes use of two simple ideas

  1. split up a larger-than-RAM dataset into chunks and store each chunk in a separate file inside a folder and
  2. provide a convenient API to manipulate these chunks

disk.frame performs a similar role to distributed systems such as Apache Spark, Python’s Dask, and Julia’s JuliaDB.jl for medium data which are datasets that are too large for RAM but not quite large enough to qualify as big data.

In this tutorial, we introduce disk.frame, address some common questions, and replicate the sparklyr data manipulation tutorial using disk.frame constructs.

Installation

Simply run

or

Set-up disk.frame

disk.frame works best if it can process multiple data chunks in parallel. The best way to set-up disk.frame so that each CPU core runs a background worker is by using

The setup_disk.frame() sets up background workers equal to the number of CPU cores; please note that, by default, hyper-threaded cores are counted as one not two.

Alternatively, one may specify the number of workers using setup_disk.frame(workers = n).

Basic Data Operations with disk.frame

The disk.frame package provides convenient functions to convert data.frames and CSVs to disk.frames.

Creating a disk.frame from CSV

If the CSV is too large to read in, then we can also use the in_chunk_size option to control how many rows to read in at once. For example to read in the data 100,000 rows at a time.

library(nycflights13)
library(disk.frame)

# write a csv
csv_path = file.path(tempdir(), "tmp_flights.csv")

data.table::fwrite(flights, csv_path)

df_path = file.path(tempdir(), "tmp_flights.df")

flights.df <- csv_to_disk.frame(
  csv_path, 
  outdir = df_path, 
  in_chunk_size = 100000)
#>  -----------------------------------------------------
#> Stage 1 of 2: splitting the file C:\Users\RTX2080\AppData\Local\Temp\Rtmpq8cAyl/tmp_flights.csv into smallers files:
#> Destination: C:\Users\RTX2080\AppData\Local\Temp\Rtmpq8cAyl\file28b02c2bed7
#>  -----------------------------------------------------
#> Stage 1 of 2 took: 0.150s elapsed (0.110s cpu)
#>  -----------------------------------------------------
#> Stage 2 of 2: Converting the smaller files into disk.frame
#>  -----------------------------------------------------
#> csv_to_disk.frame: Reading multiple input files.
#> Please use `colClasses = `  to set column types to minimize the chance of a failed read
#> =================================================
#> 
#>  -----------------------------------------------------
#> -- Converting CSVs to disk.frame -- Stage 1 of 2:
#> 
#> Converting 4 CSVs to 6 disk.frames each consisting of 6 chunks
#> 
#> -- Converting CSVs to disk.frame -- Stage 1 or 2 took: 1.250s elapsed (0.050s cpu)
#>  -----------------------------------------------------
#> 
#>  -----------------------------------------------------
#> -- Converting CSVs to disk.frame -- Stage 2 of 2:
#> 
#> Row-binding the 6 disk.frames together to form one large disk.frame:
#> Creating the disk.frame at C:\Users\RTX2080\AppData\Local\Temp\Rtmpq8cAyl/tmp_flights.df
#> 
#> Appending disk.frames:
#> Stage 2 of 2 took: 0.330s elapsed (0.110s cpu)
#>  -----------------------------------------------------
#> Stage 1 & 2 in total took: 1.580s elapsed (0.160s cpu)
#> Stage 2 of 2 took: 1.800s elapsed (0.160s cpu)
#>  -----------------------------------------------------
#> Stage 2 & 2 took: 1.950s elapsed (0.270s cpu)
#>  -----------------------------------------------------
  
flights.df
#> path: "C:\Users\RTX2080\AppData\Local\Temp\Rtmpq8cAyl/tmp_flights.df"
#> nchunks: 6
#> nrow (at source): 336776
#> ncol (at source): 19
#> nrow (post operations): ???
#> ncol (post operations): ???

disk.frame also has a function zip_to_disk.frame that can convert every CSV in a zip file to disk.frames.

Simple dplyr verbs and lazy evaluation

The class of flights.df1 is also a disk.frame after the dplyr::select transformation. Also, disk.frame operations are by default (and where possible) lazy, meaning it doesn’t perform the operations right away. Instead, it waits until you call collect. Exceptions to this rule are the *_join operations which evaluated eagerly under certain conditions see Joins for disk.frame in-depth for details.

For lazily constructed disk.frames (e.g. flights.df1). The function collect can be used to bring the results from disk into R, e.g.

Of course, for larger-than-RAM datasets, one wouldn’t call collect on the whole disk.frame (because why would you need disk.frame otherwise). More likely, one would call collect on a filtered dataset or one summarized with group_by.

Some examples of other dplyr verbs applied:

Sharding and distribution of chunks

Like other distributed data manipulation frameworks disk.frame utilizes the sharding concept to distribute the data into chunks. For example “to shard by cust_id” means that all rows with the same cust_id will be stored in the same chunk. This enables chunk_group_by by cust_id to produce the same results as non-chunked data.

The by variables that were used to shard the dataset are called the shardkeys. The sharding is performed by computing a deterministic hash on the shard keys (the by variables) for each row. The hash function produces an integer between 1 and n, where n is the number of chunks.

Grouping

The disk.frame implements the chunk_group_by operation with a significant caveat. In the disk.frame framework, group-by happens WITHIN each chunk and not ACROSS chunks. To achieve group by across chunk we need to put all rows with the same group keys into the same file chunk; this can be achieved with hard_group_by. However, the hard_group_by operation can be VERY TIME CONSUMING computationally and should be avoided if possible.

The hard_group_by operation is best illustrated with an example, suppose a disk.frame has three chunks

# chunk1 = 1.fst
#  id n
#1  a 1
#2  a 2
#3  b 3
#4  d 4

# chunk2 = 2.fst
#  id n
#1  a 4
#2  a 5
#3  b 6
#4  d 7

# chunk3 = 3.fst
#  id n
#1  a 4
#2  b 5
#3  c 6

and notice that the id column contains 3 distinct values "a","b", and "c". To perform hard_group_by(df, by = id) MAY give you the following disk.frame where all the ids with the same values end up in the same chunks.

# chunk1 = 1.fst
#  id n
#1  b 3
#2  b 6

# chunk2 = 2.fst
#  id n
#1  c 6
#2  d 4
#3  d 7

# chunk3 = 3.fst
#  id n
#1  a 1
#2  a 2
#3  a 4
#4  a 5
#5  a 4

Also, notice that there is no guaranteed order for the distribution of the ids to the chunks. The order is random, but each chunk is likely to have a similar number of rows, provided that id does not follow a skewed distribution i.e. where a few distinct values make up the majority of the rows.

Typically, chunk_group_by is performed WITHIN each chunk. This is not an issue if the chunks have already been sharded on the by variables beforehand; however, if this is not the case then one may need a second stage aggregation to obtain the correct result, see Two-stage group by.

By forcing the user to choose chunk_group_by (within each chunk) and hard_group_by (across all chunks), this ensures that the user is conscious of the choice they are making. In sparklyr the equivalent of a hard_group_by is performed, which we should avoid, where possible, as it is time-consuming and expensive. Hence, disk.frame has chosen to explain the theory and allow the user to make a conscious choice when performing group_by.

Two-stage group by

For most group-by tasks, the user can achieve the desired result WITHOUT using hard = TRUE by performing the group by in two stages. For example, suppose you aim to count the number of rows group by carrier, you can set hard = F to find the count within each chunk and then use a second group-by to summaries each chunk’s results into the desired result. For example,

Because this two-stage approach avoids the expensive hard group_by operation, it is often significantly faster. However, it can be tedious to write; and this is a con of the disk.frame chunking mechanism.

Note: this two-stage approach is similar to a map-reduce operation.

Restrict input columns for faster processing

One can restrict which input columns to load into memory for each chunk; this can significantly increase the speed of data processing. To restrict the input columns, use the srckeep function which only accepts column names as a string vector.

Input column restriction is one of the most critical efficiencies provided by disk.frame. Because the underlying format allows random access to columns (i.e. retrieve only the columns used for processing), hence one can drastically reduce the amount of data loaded into RAM for processing by keeping only those columns that are directly used to produce the results.

Joins

disk.frame supports many dplyr joins including:

In all cases, the left dataset (x) must be a disk.frame, and the right dataset (y) can be either a disk.frame or a data.frame. If the right dataset is a disk.frame and the shardkeys are different between the two disk.frames then two expensive hard group_by operations are performed eagerly, one on the left disk.frame and one on the right disk.frame to perform the joins correctly.

However, if the right dataset is a data.frame then hard_group_bys are only performed in the case of full_join.

Note disk.frame does not support right_join the user should use left_join instead.

The below joins are performed lazily because airlines.dt is a data.table not a disk.frame:

Window functions and arbitrary functions

disk.frame supports all data.frame operations, unlike Spark which can only perform those operations that Spark has implemented. Hence windowing functions like rank are supported out of the box.

Arbitrary by-chunk processing

One can apply arbitrary transformations to each chunk of the disk.frame by using the delayed function which evaluates lazily or the map.disk.frame(lazy = F) function which evaluates eagerly. For example to return the number of rows in each chunk

and to do the same with map.disk.frame

The map function can also output the results to another disk.frame folder, e.g.

Notice disk.frame supports the purrr syntax for defining a function using ~.

Sampling

In the disk.frame framework, sampling a proportion of rows within each chunk can be performed using sample_frac.

Writing Data

One can output a disk.frame by using the write_disk.frame function. E.g.

this will output a disk.frame to the folder “out”