前言

在做量化投資策略參數最佳化時,都會透過for迴圈的寫法來處理,但是如果策略很複雜時,每次迴圈跑得速度都會很慢。由於R程式是用單個執行緒在跑,因此要克服執行時間很久的問題,最簡單的方法就是在電腦上開很多個R程式一起跑。這樣就會用很多個執行緒在計算,此方法類似平行運算的概念,但這樣做實在是很麻煩。為解決這個問題,R程式裡面有一個套件parallel,只要將要程式碼包裝成函數形式,就可以跑平行運算。

接下來文章一開始會先用for迴圈做一個簡單的範例,再來會使用apply家族提升速度,最後用平行運算讓速度再往上提升。透過這個範例,可以看到如何使用平行運算及平行運算的速度。

使用套件

library(parallel)    # 平行運算套件
library(tidyverse)   # 資料科學專用套件包

For迴圈寫法

我們做一個簡單的範例,產生1,000列的資料,每列為共包含兩個數,分別為該列的值及平方值。

output <- NULL     # 建立儲存表
power <- 2         # 次方數
ptm <- proc.time() # 啟動計時器

for(ix in 1:1000){

  rowData <- tibble(num1 = ix, num2 = ix^power) 
  output <- output %>% bind_rows(rowData)
  
}

forTime <- proc.time() - ptm  # 結束計時器 

執行完後,output會是:

output
## # A tibble: 1,000 x 2
##     num1  num2
##    <int> <dbl>
##  1     1     1
##  2     2     4
##  3     3     9
##  4     4    16
##  5     5    25
##  6     6    36
##  7     7    49
##  8     8    64
##  9     9    81
## 10    10   100
## # ... with 990 more rows

for迴圈的寫法,執行速度為0.46秒:

forTime
##    user  system elapsed 
##    0.45    0.00    0.46

Apply寫法

在R語言中,透過apply家族的函數改寫for迴圈寫法,可以提升速度。我們將原先問題改為lapply寫法:

# 將For迴圈內程式碼改為函數
GenerateData <- function(ix){
  rowData <- tibble(num1 = ix, num2 = ix^power) 
  return(rowData)
}

power <- 2          # 次方數
ptm <- proc.time()  # 啟動計時器

output <- lapply(c(1:1000), GenerateData)  # 執行lapply
output <- do.call(bind_rows, output)       # 整併資料

lapplyTime <- proc.time() - ptm  # 結束計時器 
lapplyTime
##    user  system elapsed 
##    0.28    0.00    0.29

在lapply的寫法,執行的速度為0.29秒,相較於For迴圈寫法,提升0.17秒。

平行運算寫法

接下來就是進入本篇的重點平行運算寫法。

首先我們先看電腦的執行緒有幾個,這部分每台電腦都會不一樣,以我的電腦為例,共有8個。

myCoreNums <- detectCores()
myCoreNums
## [1] 8

接下來是設定待會跑程式時,要用多少的執行緒來協助我們。這邊建議執行緒最多就設定電腦執行緒個數減1,為何要減1?主要是讓電腦有1個執行緒能夠維持電腦的基本運作。如果將所有執行緒拿來跑程式,電腦有時候就會直接掛掉黑屏給你看,用平行運算也是要很小心的。

cl <- makeCluster(myCoreNums-1)

執行緒設定好後,下一個步驟是將函數內會用到的資料及套件部署到各個執行緒。這邊可以想像成我在每個執行緒中都開啟一個R程式,但這個R程式的變數區和套件都還沒被載入,所以我們要將資料及套件都傳進去執行緒內。

以範例來說,函數內的power變數並不是在function內產生,所以就要power變數部署進去。另外function內有用到tidyverse套件,也需要部署到執行緒內。

clusterExport(cl, c("power"))               # 傳入變數
clusterEvalQ(cl, c(library(tidyverse)))     # 傳入套件

此處做個補充,由於這是一個小小的範例,在這個範例內只需要部署到1個變數及1個套件就可以運作。但實際上常會需要部署很多變數及套件,寫法為:

clusterExport(cl, c("variable1", "variable2", "variable3"))                      # 傳入變數
clusterEvalQ(cl, c(library(package1), library(package2), library(package3)))     # 傳入套件

在部署完變數後,接下來就可以開始執行平行演算函數。其實很簡單,只要將lapply函數改成parLapply,第一個引數加入cl即可。

ptm <- proc.time()  # 啟動計時器

output <- parLapply(cl, c(1:1000), GenerateData)   # 執行平行運算
output <- do.call(bind_rows, output)               # 整併資料

parLapplyTime <- proc.time() - ptm  # 結束計時器 
parLapplyTime
##    user  system elapsed 
##    0.03    0.00    0.24

在平行運算的的寫法,執行的速度為0.24秒,比for迴圈寫法快0.22秒,比lapply的寫法快0.05秒。這僅僅是處理1,000筆資料的數據,當資料量大時,速度差異將會更大。

在運作完平行運算後,要記得將平行運算關掉,不要浪費電腦效能

stopCluster(cl)

以下為完整的平行運算範例程式碼:

library(parallel)    # 平行運算套件
library(tidyverse)   # 資料科學專用套件包

power <- 2 # 次方數

# 資料產生函數
GenerateData <- function(ix){
  rowData <- tibble(num1 = ix, num2 = ix^power) 
  return(rowData)
}

# 設定執行函數
myCoreNums <- detectCores()
cl <- makeCluster(myCoreNums-1)

# 部署變數及套件至執行緒
clusterExport(cl, c("power"))               # 傳入變數
clusterEvalQ(cl, c(library(tidyverse)))     # 傳入套件

ptm <- proc.time()  # 啟動計時器

output <- parLapply(cl, c(1:1000), GenerateData)   # 執行平行運算
output <- do.call(bind_rows, output)               # 整併資料

parLapplyTime <- proc.time() - ptm  # 結束計時器 

stopCluster(cl) # 結束平行運算

結語

parallel套件能使用的函數不僅僅只有parLapply,只要是apply家族的函數皆可以使用,可以在Console輸入?parLapply查詢。在寫程式時,我習慣在一開始都會先用for迴圈去處理問題,觀看部分資料執行的狀況是否正確。確認無誤要開始跑全部資料時,將for迴圈程式碼改成function寫法,並搭配apply家族函數,這樣就可以很輕易地再改寫成平行運算程式碼。目前用下來覺得平行運算套件真的是很方便,速度很快。但如果要說缺點的話,就是如果平行運算出錯時,很難去Debug,有時還是需要回到for迴圈去尋找問題發生點。

參考來源

當初是看這篇學會平行運算的寫法:https://www.r-bloggers.com/how-to-go-parallel-in-r-basics-tips/,裡面也有介紹其他R的平行演算套件,像是foreach套件等,有興趣的話可以去看看。