社交網路分析的 R 基礎:(四)迴圈與並行

張高興發表於2022-02-09

前三章中列出的大多數示例程式碼都很短,並沒有涉及到複雜的操作。從本章開始將會把前面介紹的資料結構組合起來,構成真正的程式。大部分程式是由條件語句和迴圈語句控制,R 語言中的條件語句(if-else)和 C 語言中類似此處就不再介紹,迴圈語句包括 forwhile 控制塊。迴圈是社交網路分析的主旋律,比如使用 for 迴圈遍歷分析網路中的每一個節點。當網路規模足夠大時,並行處理又變得十分必要。熟練掌握本章的內容後,你的程式將會優雅而自然。

迴圈語句

while

while 迴圈作為最簡單的一種迴圈,只要滿足條件(condition 為 TRUE),迴圈將會一直進行。

while (condition) {
    # TODO
}

在 R 語言中還存在特殊的關鍵字 repeat,在 repeat 控制塊內的語句將會無限的執行。下面的示例程式碼效果是等價的:

repeat {
    # TODO
}

while (TRUE) {
    # TODO
}

for

R 語言中的 for 迴圈更像某些語言中的 foreach,本質上就是遍歷向量(或其他資料結構)中的元素:

for (name in vector) {
    # TODO
}

下面的示例將會輸出向量中的元素:

> v <- c("a", "b", "c")
> for (item in v) {
+     print(item)
+ }
[1] "a"
[1] "b"
[1] "c"

迴圈控制

有時當滿足條件時,需要使用 break 退出迴圈:

while (TRUE) {
    # TODO
    if (condition) {
        break
    }
}

或者使用 next 退出當前迴圈(類似其他語言的 continue):

for (name in vector) {
    # TODO
    if (condition) {
        next
    }
}

apply() 系列函式

R 語言中迴圈語句的執行效率是無法忍受的,這是因為迴圈語句是基於 R 語言本身來實現的,而向量操作是基於 C 語言實現的,所以應避免使用顯式迴圈,使用 apply() 系列函式進行替代。舉個例子,對一個矩陣的行求和,並封裝一個函式,使用 for 迴圈應該是這樣:

func1 <- function(matrix) {
    row_sum <- c()
    for (i in 1: nrow(matrix)) {
        row_sum[i] <- sum(matrix[i, ])  # 對每一行求和
    }

    return(row_sum)
}

使用 sapply() 可以這樣簡化程式碼:

func2 <- function(matrix) {
    return(sapply(1: nrow(matrix), function(i) { return(sum(matrix[i, ])) }))
}

下面測試一下兩種方法的效能消耗:

> m <- matrix(c(1: (10000 * 10000)), nrow = 10000)  # 10000x10000 的方陣
> system.time(func1(m))
使用者 系統  流逝
0.79 0.00 0.79
> system.time(func2(m))
使用者 系統  流逝
0.72 0.00 0.72

上面的例子說明使用 for 迴圈不僅程式碼冗餘,而且 for 迴圈實現的計算是耗時最長的,這就是為什麼要了解 apply() 系列函式的原因。apply() 系列函式本身就是解決資料迴圈處理的問題,為了面向不同的資料型別,不同的返回值,apply() 函式組成了一個函式族。一般使用最多的是對矩陣處理的函式 apply() 以及對向量處理的函式 sapply()

image

apply() 系列函式[1]

apply()

apply() 函式用於多維資料的處理,比如矩陣。其本質上是對 for 迴圈的進一步封裝,並不會加快計算速度apply() 函式的定義如下:

apply(X, MARGIN, FUN)

? 提示

要檢視函式的文件可以在 R 終端中鍵入“?函式名”,比如檢視 apply() 的文件輸入 ?apply

其中 X 是要迴圈處理的資料,即矩陣;MARGIN 是資料處理的維度,1 是按行處理,2 是按列處理;FUN 是迴圈處理的函式。對一個矩陣的行求和使用 apply() 函式更簡單,但效率上不如 sapply()

func2 <- function(matrix) {
    return(apply(matrix, 1, sum))
}

sapply()

sapply() 函式用於迴圈處理一維資料,比如向量。引數上更加精簡,處理完成的資料返回的結果集為向量,其定義如下:

sapply(X, FUN)

其中 X 是要迴圈處理的資料,即向量;FUN 是迴圈處理的函式。在不使用向量運算的前提下計算向量的平方,使用 sapply() 函式可以這樣:

> v <- c(1, 2, 3)
> sapply(v, function(item) { return(item ^ 2) })
[1] 1 4 9

使用 parallel 包並行處理

現代 CPU 通常擁有 4 個以上的核心,為了使計算機更努力的“工作”,將任務並行化處理變得很有意義。充分利用多核 CPU,執行速度可能會快四倍,這樣我們等待實驗的時間更少,並且可以執行更多的實驗。在開始將任務並行化之前,首先需要問自己一個問題:任務是否能夠並行?要回答這個問題,你需要思考任務是否具有“重複性”,即每個子任務可以保持計算的獨立性,只有可重複的任務才能分配到多個 CPU 上執行。回到上文中“對一個矩陣的行求和”這個問題上,“求和”是一個可重複的任務,矩陣的行數決定了“求和”的次數,對矩陣中某一行向量的求和並不會干擾其他行向量的求和,因此該問題可以進行並行處理。或者更簡單的說,包含在迴圈控制塊內的程式碼基本都可以進行並行處理。

在 R 語言中平行計算有 snowparallel 兩個包可選,兩個包功能上一樣,這裡使用 parallel,最直接的原因是 R 語言整合了這個包,無需額外安裝。並行函式的用法基本等同於 apply() 系列函式,比如:apply() 對應的平行計算函式為 parApply()sapply() 對應的平行計算函式為 parSapply() 等等。

在本機上並行

在本機上處理平行計算的概念很好理解,就是將需要並行處理的任務分配到計算機的多個 CPU 核心中,這也是最常見的場景。繼續以“對一個矩陣的行求和”為例,採用並行的方式解決這個問題。首先需要建立一個並行叢集:

> library(parallel)
> parallel.cores <- detectCores()  # 檢測本機的核心數
> cl <- makeCluster(parallel.cores)  # 建立叢集,從機的數量為核心數

? 提示

通常建立叢集的從機數量不要超過 最大核心數 - 1,最好保留 1~2 個核心供系統排程以及其他任務使用。

如果沒有任何錯誤提示的話,則本機叢集建立完成,可以將建立的叢集列印出來以檢視資訊。

> print(cl)
socket cluster with 16 nodes on host 'localhost'

? 提示

本機叢集的建立錯誤通常和埠占用有關,處理該問題可以檢視埠的佔用情況並結束程式,或者重啟計算機。

緊接著呼叫 parApply() 進行平行計算,平行計算的 parApply() 系列方法僅僅需要在第一個引數將建立的叢集傳遞進去即可。

func3 <- function(cluster, matrix) {
    return(parApply(cluster, matrix, 1, sum))
}

下面來測試一下平行計算的時間開銷:

> system.time(func3(cl, m))
使用者 系統  流逝
3.43 0.47 4.86

測試的結果似乎與想象的有些不同,時間變得更慢了。這是由於 parallel 建立的是套接字叢集,從機之間的通訊速度是較慢的,由於求和這個任務本身就很簡單,通訊的開銷遠遠大於計算的時間消耗,因此導致了計算速度並沒有變得更快。這也告訴我們過於“輕鬆”的任務,並不需要並行執行。

最後在平行計算完成後需要及時關閉叢集:

> stopCluster(cl)

由於叢集是一個獨立的環境,本地環境所引入的包、擁有的變數在叢集內是無法訪問的。在進行更復雜的並行任務時,需要將包或者變數傳遞至叢集中:

> clusterEvalQ(cl, { library(igraph) })  # 為叢集引入包
> clusterExport(cl, c("graph", "subgraph"), envir = environment())  # 為叢集引入本地變數

在多臺計算機上並行

由於 parallel 建立的是套接字叢集,這使得將並行任務分配至多臺計算機成為可能。當然這並不意味著計算機越多就能獲得更快的計算速度。parallel 分配任務的方式類似均分,如果計算機之間單核的效能差距過大,那麼會出現一臺計算機分配的任務已經完成而等待其他計算機的現象,這樣反而會出現計算速度的下降。並且平行計算的速度還與計算機之間的通訊速度有關,從機的變數共享來自於主機,當網路情況不佳時,通訊的消耗也是不容忽視的。因此在多臺計算機上進行並行任務時需要謹慎考慮。在多臺計算機上並行與在本機上並行的區別僅在於叢集的建立,因此本小節將只介紹叢集建立的不同。

這裡使用兩臺計算機進行模擬實驗,主機的作業系統為 Windows 10,從機的作業系統為 Ubuntu 20.04,使用兩臺安裝了不同作業系統的計算機模擬了最複雜的情況,拓撲圖如下所示。

image

? 提示

計算機之間的通訊需要 SSH,Windows 10 請在“可選功能”中新增“OpenSSH 伺服器”,Ubuntu Desktop 請執行命令 apt install openssh-server

同時為了避免在建立叢集時手動輸入 SSH 登入密碼,請配置 SSH 金鑰登入。

首先建立一個列表,用於配置叢集計算機的資訊。其中 host 為計算機的地址;user 為 SSH 登入的使用者名稱;rscript 為 Rscript 程式的路徑,當主從機的作業系統相同時該欄位可以省略;ncore 為分配的 CPU 核心數。

> master <- '192.168.122.100'
> addresses <- list(
+     list(host = master, user = "zhang", rscript = "C:/Program Files/R/R-4.0.5/bin/Rscript", ncore = 4),
+     list(host = "192.168.122.200", user = "zhang", rscript = "/usr/lib/R/bin/Rscript", ncore = 4)
+ )

由於 parallel 是將一個 CPU 核心作為從機,而上面的配置是按照計算機進行的,因此還需要根據 ncore 欄位建立分配 CPU 核心數的從機:

> spec <- lapply(addresses, function(machine) {
+     rep(list(list(host = machine$host, user = machine$user, rscript = machine$rscript)), machine$ncore)
+ })
> spec <- unlist(spec, recursive = FALSE)

可以將建立的 spec 變數列印出來,觀察是否建立了 8 個從機的資訊。

> length(addresses)
[1] 2
> length(spec)     
[1] 8

緊接著就可以呼叫 makeCluster() 建立叢集,此過程根據計算機的數量可能需要數分鐘。其中 manual 為是否手動啟用從機,當建立叢集出現問題時,可以將該欄位設定為 TRUE,根據提示手動啟用從機,以此來觀察哪一臺計算機出現了問題;outfile 為日誌檔案的儲存地址,當建立叢集出現問題時,也可以檢視該檔案。

cl <- makeCluster(type = "PSOCK", master = master, spec = spec, manual = FALSE, outfile = "log.txt")

此時如果沒有提示任何錯誤,那麼一個由多臺計算機組成的叢集已經建立完成。現在可以使用 parApply() 系列函式將任務並行的在多臺計算機上執行。

> print(cl)
socket cluster with 8 nodes on hosts
                    '192.168.122.100', '192.168.122.200'

? 提示

多臺計算機叢集的建立錯誤通常與 SSH 登入和包的引用有關。SSH 登入的錯誤根據提示資訊進行處理,包引用的錯誤請確保計算機之間的 R 語言版本、包的版本一致。

✏️ 練習

1. 使用 for 迴圈倒序輸出 0~100;

2. 定義一個函式,使用 apply() 系列函式,求一個矩陣列向量的平均值。

參考

  1. 掌握R語言中的apply函式族 | 粉絲日誌
  2. Running R jobs quickly on many machines – Win Vector LLC

相關文章