|
| 1 | +--- |
| 2 | +pre: <b>2/27. </b> |
| 3 | +title: "Parallel" |
| 4 | +weight: 14 |
| 5 | +summary: "Learn to parallelize R code." |
| 6 | +format: |
| 7 | + gfm: |
| 8 | + toc: true |
| 9 | + output-file: "_index.en.md" |
| 10 | + reference-links: true |
| 11 | + code-link: true |
| 12 | +editor_options: |
| 13 | + chunk_output_type: console |
| 14 | +--- |
| 15 | + |
| 16 | +```{r, include=FALSE} |
| 17 | +answers = FALSE |
| 18 | +``` |
| 19 | + |
| 20 | +## Overview |
| 21 | + |
| 22 | +Parallelizing code can lead to some impressive performance gains, but it is not a silver bullet. Not only does your problem need to be easily compartmentalized to run in parallel, but bottlenecks in your code flow can actually make code *slower* if run in parallel. Today we'll run a few tests to try and get a sense of when it is and is not helpful to parallelize our code. |
| 23 | + |
| 24 | +You are going to need the `future.apply` and `tictoc` packages for the worksheet today, so be sure to install them. |
| 25 | + |
| 26 | +## Baseline Sequential Execution |
| 27 | + |
| 28 | +Before we get into running code in parallel, let's establish a baseline for your machine. Execute the following code to define a function that will find prime numbers between 1 and `n`. |
| 29 | + |
| 30 | +```{r} |
| 31 | +# Function taken from John on this stack overflow post |
| 32 | +# https://stackoverflow.com/questions/3789968/generate-a-list-of-primes-up-to-a-certain-number |
| 33 | +getPrimeNumbers <- function(n, dead_weight) { |
| 34 | + n <- as.integer(n) |
| 35 | + if(n > 1e6) stop("n too large") |
| 36 | + primes <- rep(TRUE, n) |
| 37 | + primes[1] <- FALSE |
| 38 | + last.prime <- 2L |
| 39 | + for(i in last.prime:floor(sqrt(n))) |
| 40 | + { |
| 41 | + primes[seq.int(2L*last.prime, n, last.prime)] <- FALSE |
| 42 | + last.prime <- last.prime + min(which(primes[(last.prime+1):n])) |
| 43 | + } |
| 44 | + which(primes) |
| 45 | +} |
| 46 | +``` |
| 47 | + |
| 48 | +To get our baseline, we're going to iterate over a numeric vector, and calculate all the prime numbers for those values. We don't actually need to save these values for anything, so I'll just assign them to `primes` and the overwrite it. |
| 49 | + |
| 50 | +::: {.question} |
| 51 | +Run the following code and write down how long it takes to execute somewhere easy to reference later. You need to execute all of it at once for the tic-toc timer to work! |
| 52 | + |
| 53 | +```{r, eval=FALSE} |
| 54 | +# load in tictoc for easy benchmarking |
| 55 | +library(tictoc) |
| 56 | +
|
| 57 | +# start timer |
| 58 | +tic() |
| 59 | +
|
| 60 | +# set our vector to iterate through |
| 61 | +num_vec = 10:9001 |
| 62 | +
|
| 63 | +# get primes |
| 64 | +for(i in num_vec){ |
| 65 | + primes = getPrimeNumbers(i) |
| 66 | +} |
| 67 | +
|
| 68 | +# stop timer |
| 69 | +toc() |
| 70 | +``` |
| 71 | + |
| 72 | +::: |
| 73 | + |
| 74 | +For a comparison, let's run the same code as a regular apply function. |
| 75 | + |
| 76 | +::: {.question} |
| 77 | +Run the following code and write down how long it takes to execute somewhere easy to reference later. You need to execute all of it at once for the tic-toc timer to work! There should be a small improvement over the for loop. |
| 78 | + |
| 79 | +```{r, eval=FALSE} |
| 80 | +# start timer |
| 81 | +tic() |
| 82 | +
|
| 83 | +# set our vector to iterate through |
| 84 | +num_vec = 10:9001 |
| 85 | +
|
| 86 | +# get primes |
| 87 | +primes = sapply(num_vec, getPrimeNumbers) |
| 88 | +
|
| 89 | +# stop timer |
| 90 | +toc() |
| 91 | +``` |
| 92 | + |
| 93 | +::: |
| 94 | + |
| 95 | +The time difference in this case will be minimal given the small scale of our code. For example, the for loop for me was 22.78 seconds, while the `sapply()` was 21.19 seconds; a difference of ~7%[^1]. 7% of a few seconds in negligible, but 7% of an hour (~4 minutes) is time to get a snack. 7% of a work day (~33 mins) gets you an extra lunch break. 7% of a week may get you your results a full half work day early! Let's take it even further. |
| 96 | + |
| 97 | +[^1]: The actual percent things will differ relies on a ton of factors. Don't take this as a representative number. |
| 98 | + |
| 99 | +## Setting a Parallel Plan |
| 100 | + |
| 101 | +To prepare for running out first bit of code in parallel, we need to understand the specs of your machine a bit better. It is completely possible to crash your computer if you ask it to run too much code at once. One of the most common culprits of this is asking your computer to use too many cores. |
| 102 | + |
| 103 | +To determine how many cores you have on your computer, run the following and keep track of the number. |
| 104 | + |
| 105 | +```{r, eval=FALSE} |
| 106 | +# Get the number of cores on this machine |
| 107 | +parallel::detectCores() |
| 108 | +
|
| 109 | +# the max cores we should possibly use |
| 110 | +max_safe = parallel::detectCores() - 1 |
| 111 | +``` |
| 112 | + |
| 113 | +You could theoretically run as many streams of code as you have cores, but you really shouldn't. To find the max, take whatever number you just got and lower it by one. This will always leave one core free for your computer to do other important things, like let you move the mouse. |
| 114 | + |
| 115 | +Once we have than number in mind, we can start preparing for running code in parallel. We'll be using the `future` package to coordinate our parallel code; more specifically the `future.apply` package. `future` lets you write parallel code in a generic way, and then adapt how the code is actually executed later. This can be really helpful if you swap between machines often like I do. |
| 116 | + |
| 117 | +To create our parallel plan, all we need to do is load the `future.apply` package, and run the `plan()` function. The options for our plan are as follows: |
| 118 | + |
| 119 | +| Name | OSes | Description | |
| 120 | +|-------------------|-------------------------|---------------------------------------------------------------| |
| 121 | +| **synchronous:** | | **non-parallel:** | |
| 122 | +| sequential | all | sequentially and in the current R process | |
| 123 | +| **asynchronous:** | | **parallel:** | |
| 124 | +| multisession | all | background R sessions (on current machine) | |
| 125 | +| multicore | not Windows/not RStudio | forked R processes (on current machine) | |
| 126 | +| cluster | all | external R sessions on current, local, and/or remote machines | |
| 127 | + |
| 128 | +R normally runs *sequentially*, meaning it runs code one thing at at time in order. We can specify that as our plan if we wanted, but then nothing would really change. The most robust plan type is *multisession*, where we create multiple copies of R to handle different sections of our code at once; we'll do that here. To specify that is our plan, run the following code. You'll notice I also set the argument `workers` to 2. This means we essentially will have 2 copies of R working at the same time for us. |
| 129 | + |
| 130 | +```{r, eval=FALSE} |
| 131 | +# load the package |
| 132 | +library(future.apply) |
| 133 | +
|
| 134 | +# set our plan |
| 135 | +plan(multisession, workers = 2) |
| 136 | +``` |
| 137 | + |
| 138 | +## Simple Parallel |
| 139 | + |
| 140 | +Now that we have our plan set, let's run our code from above again. This time, we'll use the `future_sapply()` function to run it in parallel. |
| 141 | + |
| 142 | +::: {.question} |
| 143 | +Run the following code and write down how long it takes to execute somewhere easy to reference later. |
| 144 | + |
| 145 | +```{r, eval=FALSE} |
| 146 | +# start timer |
| 147 | +tic() |
| 148 | +
|
| 149 | +# set our vector to iterate through |
| 150 | +num_vec = 10:9001 |
| 151 | +
|
| 152 | +# get primes |
| 153 | +primes = future_sapply(num_vec, getPrimeNumbers) |
| 154 | +
|
| 155 | +# stop timer |
| 156 | +toc() |
| 157 | +``` |
| 158 | + |
| 159 | +::: |
| 160 | + |
| 161 | +You should see a more sizable decrease in execution time, for me it went from 21.19 seconds with a regular apply, to 14.92 seconds with 2 workers. What happens if we add more workers? |
| 162 | + |
| 163 | +::: {.question} |
| 164 | +Run the following code and write down how long it takes to execute somewhere easy to reference later. Notice that I've changed our plan to 3 workers. |
| 165 | + |
| 166 | +```{r, eval=FALSE} |
| 167 | +# start timer |
| 168 | +tic() |
| 169 | +
|
| 170 | +# change out plan to 3 workers |
| 171 | +plan(multisession, workers = 3) |
| 172 | +
|
| 173 | +# set our vector to iterate through |
| 174 | +num_vec = 10:9001 |
| 175 | +
|
| 176 | +# get primes |
| 177 | +primes = future_sapply(num_vec, getPrimeNumbers) |
| 178 | +
|
| 179 | +# stop timer |
| 180 | +toc() |
| 181 | +``` |
| 182 | + |
| 183 | +::: |
| 184 | + |
| 185 | +Down to 12.19 second for me, including the time it takes to change out plan! Let's go full-bore and use our maximum safe number of cores from above. |
| 186 | + |
| 187 | +::: {.question} |
| 188 | +Run the following code and write down how long it takes to execute somewhere easy to reference later. Notice that I've changed our plan to 3 workers. |
| 189 | + |
| 190 | +```{r, eval=FALSE} |
| 191 | +# start timer |
| 192 | +tic() |
| 193 | +
|
| 194 | +# change out plan to max safe workers |
| 195 | +plan(multisession, workers = max_safe) |
| 196 | +
|
| 197 | +# set our vector to iterate through |
| 198 | +num_vec = 10:9001 |
| 199 | +
|
| 200 | +# get primes |
| 201 | +primes = future_sapply(num_vec, getPrimeNumbers) |
| 202 | +
|
| 203 | +# stop timer |
| 204 | +toc() |
| 205 | +``` |
| 206 | + |
| 207 | +::: |
| 208 | + |
| 209 | +Working with as many cores as I can spare, the code got down to 8.14 seconds. That's a full *80% faster*, which is a huge deal! |
| 210 | + |
| 211 | +## What's the Catch? |
| 212 | + |
| 213 | +We can get such large improvements with this code because it is simple, and there are no *bottlenecks* in our code. In general, a bottleneck is the slowest part of a system, so called because everything else has to wait for it to finish. For example, say we are thinking of building a new desktop computer. We need to select our new CPU, hard drive, and RAM. All three of these are reliant on each other for the computer to work quickly. If we spend all of our funds on getting the fastest possible CPU, but buy a really slow hard drive, then the CPU will just be sitting around waiting for the hard drive to relay whatever data the CPU requested. |
| 214 | + |
| 215 | +The main purpose of running code in parallel is to get around the bottleneck of only being able to execute one piece of code at a time. By running code in parallel on multiple cores, we can do several computations at once, meaning we can get to the next step faster. However, there is almost always a next step, and it may not be parallelizable. |
| 216 | + |
| 217 | +### Data Transfer |
| 218 | + |
| 219 | +One of the most common bottlenecks is caused by data transfer. We we start all these workers for R, each one needs to get a full copy of the data before it can start working. Try running the following, where I include a large data object in our `sapply()` as an argument. |
| 220 | + |
| 221 | +::: {.question} |
| 222 | +Run the following code and write down how long it takes to execute somewhere easy to reference later. |
| 223 | + |
| 224 | +```{r, eval=FALSE} |
| 225 | +# start timer |
| 226 | +tic() |
| 227 | +
|
| 228 | +# change out plan to max safe workers |
| 229 | +plan(multisession, workers = max_safe) |
| 230 | +
|
| 231 | +# set our vector to iterate through |
| 232 | +num_vec = 10:9001 |
| 233 | +
|
| 234 | +# make dead weight |
| 235 | +dead_weight = matrix(1:9999999) |
| 236 | +
|
| 237 | +# get primes |
| 238 | +primes = future_sapply(num_vec, getPrimeNumbers, dead_weight) |
| 239 | +
|
| 240 | +# stop timer |
| 241 | +toc() |
| 242 | +``` |
| 243 | + |
| 244 | +::: |
| 245 | + |
| 246 | +You should see that the time to run has stared creeping back upwards. This was a relatively simple example, but as the data to transfer gets larger, or the steps that can't be parallelized become more common, we start to lose the gains parallel code gets us. |
| 247 | + |
| 248 | +### I/O Constraints |
| 249 | + |
| 250 | +The above example has to do with the limits of our CPU. We can also run in to trouble with other parts of our hardware. For example, we can make more workers to do computations for us, but if one of our steps is loading data, it may not matter. This is because we run into the limits of our hard drives. No matter how many workers we have, if we hit the speed limit on our hard drive loading data in to R, all those workers will just have to sit and wait. |
| 251 | + |
| 252 | +### Dependency |
| 253 | + |
| 254 | +It is also important to note that **none** of this works if there is dependency in our code. All tasks to be done in parallel need to be independent from each other, otherwise we can't run several of them at once. |
| 255 | + |
| 256 | +### A Note on Shared Environments |
| 257 | + |
| 258 | +Thus far I have assumed you are working on your own personal computer. If you task is large enough that you need to parallelize things, you may be working on a server or some other shared environment. It is **critical** to note in these situations that running your code in parallel can really make things difficult for other people. |
| 259 | + |
| 260 | +Consider our above code to determine the maximum safe number of workers to use. We just took the number of cores our machine has and subtracted one so we could keep using it while the code runs. If you do this on a server, you will be taking every single core that every other person is using, and then just leaving one so you can still check the status. In effect, you take this massive shared resource and say: "this is all mine." That is what is called a jerk move. |
| 261 | + |
| 262 | +The server admins will notice and they may not be happy. |
| 263 | + |
| 264 | +## Conclusion |
| 265 | + |
| 266 | +Running code in parallel is cool, and can be helpful. But it is important to know the limitations--and when it can actually be detrimental. Be sure you keep these conditions in mind when you decide to parallelize code or not. |
| 267 | + |
| 268 | + |
| 269 | + |
0 commit comments