一、概述
CPU從三十多年前的8086,到十年前的奔騰,再到當下的多核i7。一開始,以單核cpu的主頻為目標,架構的改良和積體電路工藝的進步使得cpu的效能高速上升,單核cpu的主頻從老爺車的MHz階段一度接近4GHz高地。然而,也因為工藝和功耗等的限制,單核cpu遇到了人生的天花板,急需轉換思維,以滿足無止境的效能需求。多核cpu在此登上歷史舞臺。給你的老爺車多加兩個引擎,讓你有法拉利的感覺。現時代,連手機都到處叫囂自己有4核8核處理器的時代,PC就更不用說了。
扯遠了,anyway,對於俺們程式設計師來說,如何利用如此強大的引擎完成我們的任務才是我們要考慮的。隨著大規模資料處理、大規模問題和複雜系統求解需求的增加,以前的單核程式設計已經有心無力了。如果程式一跑就得幾個小時,甚至一天,想想都無法原諒自己。那如何讓自己更快的過度到高大上的多核並行程式設計中去呢?哈哈,廣大人民的力量!
目前工作中我所接觸到的並行處理框架主要有MPI、OpenMP和MapReduce(Hadoop)三個(CUDA屬於GPU並行程式設計,這裡不提及)。MPI和Hadoop都可以在叢集中執行,而OpenMP因為共享儲存結構的關係,不能在叢集上執行,只能單機。另外,MPI可以讓資料保留在記憶體中,可以為節點間的通訊和資料互動儲存上下文,所以能執行迭代演算法,而Hadoop卻不具有這個特性。因此,需要迭代的機器學習演算法大多使用MPI來實現。當然了,部分機器學習演算法也是可以通過設計使用Hadoop來完成的。(淺見,如果錯誤,希望各位不吝指出,謝謝)。
本文主要介紹Python環境下MPI程式設計的實踐基礎。
二、MPI與mpi4py
MPI是Message Passing Interface的簡稱,也就是訊息傳遞。訊息傳遞指的是並行執行的各個程式具有自己獨立的堆疊和程式碼段,作為互不相關的多個程式獨立執行,程式之間的資訊互動完全通過顯示地呼叫通訊函式來完成。
Mpi4py是構建在mpi之上的python庫,使得python的資料結構可以在程式(或者多個cpu)之間進行傳遞。
2.1、MPI的工作方式
很簡單,就是你啟動了一組MPI程式,每個程式都是執行同樣的程式碼!然後每個程式都有一個ID,也就是rank來標記我是誰。什麼意思呢?假設一個CPU是你請的一個工人,共有10個工人。你有100塊磚頭要搬,然後很公平,讓每個工人搬10塊。這時候,你把任務寫到一個任務卡里面,讓10個工人都執行這個任務卡中的任務,也就是搬磚!這個任務卡中的“搬磚”就是你寫的程式碼。然後10個CPU執行同一段程式碼。需要注意的是,程式碼裡面的所有變數都是每個程式獨有的,雖然名字相同。
例如,一個指令碼test.py,裡面包含以下程式碼:
1 2 3 |
from mpi4py import MPI print("hello world'') print("my rank is: %d" %MPI.rank) |
然後我們在命令列通過以下方式執行:
#mpirun –np 5 python test.py
-np5 指定啟動5個mpi程式來執行後面的程式。相當於對指令碼拷貝了5份,每個程式執行一份,互不干擾。在執行的時候程式碼裡面唯一的不同,就是各自的rank也就是ID不一樣。所以這個程式碼就會列印5個hello world和5個不同的rank值,從0到4.
2.2、點對點通訊
點對點通訊(Point-to-PointCommunication)的能力是資訊傳遞系統最基本的要求。意思就是讓兩個程式直接可以傳輸資料,也就是一個傳送資料,另一個接收資料。介面就兩個,send和recv,來個例子:
1 2 3 4 5 6 7 8 9 10 11 12 |
import mpi4py.MPI as MPI comm = MPI.COMM_WORLD comm_rank = comm.Get_rank() comm_size = comm.Get_size() # point to point communication data_send = [comm_rank]*5 comm.send(data_send,dest=(comm_rank+1)%comm_size) data_recv =comm.recv(source=(comm_rank-1)%comm_size) print("my rank is %d, and Ireceived:" % comm_rank) print data_recv |
啟動5個程式執行以上程式碼,結果如下:
1 2 3 4 5 6 7 8 9 10 |
my rank is 0, and I received: [4, 4, 4, 4, 4] my rank is 1, and I received: [0, 0, 0, 0, 0] my rank is 2, and I received: [1, 1, 1, 1, 1] my rank is 3, and I received: [2, 2, 2, 2, 2] my rank is 4, and I received: [3, 3, 3, 3, 3] |
可以看到,每個程式都建立了一個陣列,然後把它傳遞給下一個程式,最後的那個程式傳遞給第一個程式。comm_size就是mpi的程式個數,也就是-np指定的那個數。MPI.COMM_WORLD 表示程式所在的通訊組。
但這裡面有個需要注意的問題,如果我們要傳送的資料比較小的話,mpi會快取我們的資料,也就是說執行到send這個程式碼的時候,會快取被send的資料,然後繼續執行後面的指令,而不會等待對方程式執行recv指令接收完這個資料。但是,如果要傳送的資料很大,那麼程式就是掛起等待,直到接收程式執行了recv指令接收了這個資料,程式才繼續往下執行。所以上述的程式碼傳送[rank]*5沒啥問題,如果傳送[rank]*500程式就會半死不活的樣子了。因為所有的程式都會卡在傳送這條指令,等待下一個程式發起接收的這個指令,但是程式是執行完傳送的指令才能執行接收的指令,這就和死鎖差不多了。所以一般,我們將其修改成以下的方式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import mpi4py.MPI as MPI comm = MPI.COMM_WORLD comm_rank = comm.Get_rank() comm_size = comm.Get_size() data_send = [comm_rank]*5 if comm_rank == 0: comm.send(data_send, dest=(comm_rank+1)%comm_size) if comm_rank > 0: data_recv = comm.recv(source=(comm_rank-1)%comm_size) comm.send(data_send, dest=(comm_rank+1)%comm_size) if comm_rank == 0: data_recv = comm.recv(source=(comm_rank-1)%comm_size) print("my rank is %d, and Ireceived:" % comm_rank) print data_recv |
第一個程式一開始就傳送資料,其他程式一開始都是在等待接收資料,這時候程式1接收了程式0的資料,然後傳送程式1的資料,程式2接收了,再傳送程式2的資料……知道最後程式0接收最後一個程式的資料,從而避免了上述問題。
一個比較常用的方法是封一個組長,也就是一個主程式,一般是程式0作為主程式leader。主程式將資料傳送給其他的程式,其他的程式處理資料,然後返回結果給程式0。換句話說,就是程式0來控制整個資料處理流程。
2.3、群體通訊
點對點通訊是A傳送給B,一個人將自己的祕密告訴另一個人,群體通訊(Collective Communications)像是拿個大喇叭,一次性告訴所有的人。前者是一對一,後者是一對多。但是,群體通訊是以更有效的方式工作的。它的原則就一個:儘量把所有的程式在所有的時刻都使用上!我們在下面的bcast小節講述。
群體通訊還是傳送和接收兩類,一個是一次性把資料發給所有人,另一個是一次性從所有人那裡回收結果。
1)廣播bcast
將一份資料傳送給所有的程式。例如我有200份資料,有10個程式,那麼每個程式都會得到這200份資料。
1 2 3 4 5 6 7 8 9 10 11 |
import mpi4py.MPI as MPI comm = MPI.COMM_WORLD comm_rank = comm.Get_rank() comm_size = comm.Get_size() if comm_rank == 0: data = range(comm_size) data = comm.bcast(data if comm_rank == 0else None, root=0) print 'rank %d, got:' % (comm_rank) print data |
結果如下:
1 2 3 4 5 6 7 8 9 10 |
rank 0, got: [0, 1, 2, 3, 4] rank 1, got: [0, 1, 2, 3, 4] rank 2, got: [0, 1, 2, 3, 4] rank 3, got: [0, 1, 2, 3, 4] rank 4, got: [0, 1, 2, 3, 4] |
Root程式自己建了一個列表,然後廣播給所有的程式。這樣所有的程式都擁有了這個列表。然後愛幹嘛就幹嘛了。
對廣播最直觀的觀點是某個特定程式將資料一一傳送給每個程式。假設有n個程式,那麼假設我們的資料在0程式,那麼0程式就需要將資料傳送給剩下的n-1個程式,這是非常低效的,複雜度是O(n)。那有沒有高效的方式?一個最常用也是非常高效的手段是規約樹廣播:收到廣播資料的所有程式都參與到資料廣播的過程中。首先只有一個程式有資料,然後它把它傳送給第一個程式,此時有兩個程式有資料;然後這兩個程式都參與到下一次的廣播中,這時就會有4個程式有資料,……,以此類推,每次都會有2的次方個程式有資料。通過這種規約樹的廣播方法,廣播的複雜度降為O(log n)。這就是上面說的群體通訊的高效原則:充分利用所有的程式來實現資料的傳送和接收。
2)散播scatter
將一份資料平分給所有的程式。例如我有200份資料,有10個程式,那麼每個程式會分別得到20份資料。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import mpi4py.MPI as MPI comm = MPI.COMM_WORLD comm_rank = comm.Get_rank() comm_size = comm.Get_size() if comm_rank == 0: data = range(comm_size) print data else: data = None local_data = comm.scatter(data, root=0) print 'rank %d, got:' % comm_rank print local_data |
結果如下:
1 2 3 4 5 6 7 8 9 10 11 |
[0, 1, 2, 3, 4] rank 0, got: 0 rank 1, got: 1 rank 2, got: 2 rank 3, got: 3 rank 4, got: 4 |
這裡root程式建立了一個list,然後將它散播給所有的程式,相當於對這個list做了劃分,每個程式獲得等分的資料,這裡就是list的每一個數。(主要根據list的索引來劃分,list索引為第i份的資料就傳送給第i個程式)。如果是矩陣,那麼就等分的劃分行,每個程式獲得相同的行數進行處理。
需要注意的是,MPI的工作方式是每個程式都會執行所有的程式碼,所以每個程式都會執行scatter這個指令,但只有root執行它的時候,它才兼備傳送者和接收者的身份(root也會得到屬於自己的資料),對於其他程式來說,他們都只是接收者而已。
3)收集gather
那有傳送,就有一起回收的函式。Gather是將所有程式的資料收集回來,合併成一個列表。下面聯合scatter和gather組成一個完成的分發和收回過程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import mpi4py.MPI as MPI comm = MPI.COMM_WORLD comm_rank = comm.Get_rank() comm_size = comm.Get_size() if comm_rank == 0: data = range(comm_size) print data else: data = None local_data = comm.scatter(data, root=0) local_data = local_data * 2 print 'rank %d, got and do:' % comm_rank print local_data combine_data = comm.gather(local_data,root=0) if comm_rank == 0: printcombine_data |
結果如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
[0, 1, 2, 3, 4] rank 0, got and do: 0 rank 1, got and do: 2 rank 2, got and do: 4 rank 4, got and do: 8 rank 3, got and do: 6 [0, 2, 4, 6, 8] |
Root程式將資料通過scatter等分發給所有的程式,等待所有的程式都處理完後(這裡只是簡單的乘以2),root程式再通過gather回收他們的結果,和分發的原則一樣,組成一個list。Gather還有一個變體就是allgather,可以理解為它在gather的基礎上將gather的結果再bcast了一次。啥意思?意思是root程式將所有程式的結果都回收統計完後,再把整個統計結果告訴大家。這樣,不僅root可以訪問combine_data,所有的程式都可以訪問combine_data了。
4)規約reduce
規約是指不但將所有的資料收集回來,收集回來的過程中還進行了簡單的計算,例如求和,求最大值等等。為什麼要有這個呢?我們不是可以直接用gather全部收集回來了,再對列表求個sum或者max就可以了嗎?這樣不是累死組長嗎?為什麼不充分使用每個工人呢?規約實際上是使用規約樹來實現的。例如求max,完成可以讓工人兩兩pk後,再返回兩兩pk的最大值,然後再對第二層的最大值兩兩pk,直到返回一個最終的max給組長。組長就非常聰明的將工作分配下工人高效的完成了。這是O(n)的複雜度,下降到O(log n)(底數為2)的複雜度。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import mpi4py.MPI as MPI comm = MPI.COMM_WORLD comm_rank = comm.Get_rank() comm_size = comm.Get_size() if comm_rank == 0: data = range(comm_size) print data else: data = None local_data = comm.scatter(data, root=0) local_data = local_data * 2 print 'rank %d, got and do:' % comm_rank print local_data all_sum = comm.reduce(local_data, root=0,op=MPI.SUM) if comm_rank == 0: print 'sumis:%d' % all_sum |
結果如下:
1 2 3 4 5 6 7 8 9 10 11 12 |
[0, 1, 2, 3, 4] rank 0, got and do: 0 rank 1, got and do: 2 rank 2, got and do: 4 rank 3, got and do: 6 rank 4, got and do: 8 sum is:20 |
可以看到,最後可以得到一個sum值。
三、常見用法
3.1、對一個檔案的多個行並行處理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
#!usr/bin/env python #-*- coding: utf-8 -*- import sys import os import mpi4py.MPI as MPI import numpy as np # # Global variables for MPI # # instance for invoking MPI relatedfunctions comm = MPI.COMM_WORLD # the node rank in the whole community comm_rank = comm.Get_rank() # the size of the whole community, i.e.,the total number of working nodes in the MPI cluster comm_size = comm.Get_size() if __name__ == '__main__': if comm_rank == 0: sys.stderr.write("processor root starts reading data...\n") all_lines = sys.stdin.readlines() all_lines = comm.bcast(all_lines if comm_rank == 0 else None, root = 0) num_lines = len(all_lines) local_lines_offset = np.linspace(0, num_lines, comm_size +1).astype('int') local_lines = all_lines[local_lines_offset[comm_rank] :local_lines_offset[comm_rank + 1]] sys.stderr.write("%d/%d processor gets %d/%d data \n" %(comm_rank, comm_size, len(local_lines), num_lines)) cnt = 0 for line in local_lines: fields = line.strip().split('\t') cnt += 1 if cnt % 100 == 0: sys.stderr.write("processor %d has processed %d/%d lines \n" %(comm_rank, cnt, len(local_lines))) output = line.strip() + ' process every line here' print output |
3.2、對多個檔案並行處理
如果我們的檔案太大,例如幾千萬行,那麼mpi是沒辦法將這麼大的資料bcast給所有的程式的,所以我們可以先把大的檔案split成小的檔案,再讓每個程式處理少數的檔案。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
#!usr/bin/env python #-*- coding: utf-8 -*- import sys import os import mpi4py.MPI as MPI import numpy as np # # Global variables for MPI # # instance for invoking MPI relatedfunctions comm = MPI.COMM_WORLD # the node rank in the whole community comm_rank = comm.Get_rank() # the size of the whole community, i.e.,the total number of working nodes in the MPI cluster comm_size = comm.Get_size() if __name__ == '__main__': if len(sys.argv) != 2: sys.stderr.write("Usage: python *.py directoty_with_files\n") sys.exit(1) path = sys.argv[1] if comm_rank == 0: file_list = os.listdir(path) sys.stderr.write("%d files\n" % len(file_list)) file_list = comm.bcast(file_list if comm_rank == 0 else None, root = 0) num_files = len(file_list) local_files_offset = np.linspace(0, num_files, comm_size +1).astype('int') local_files = file_list[local_files_offset[comm_rank] :local_files_offset[comm_rank + 1]] sys.stderr.write("%d/%d processor gets %d/%d data \n" %(comm_rank, comm_size, len(local_files), num_files)) cnt = 0 for file_name in local_files: hd = open(os.path.join(path, file_name)) for line in hd: output = line.strip() + ' process every line here' print output cnt += 1 sys.stderr.write("processor %d has processed %d/%d files \n" %(comm_rank, cnt, len(local_files))) hd.close() |
3.3、聯合numpy對矩陣的多個行或者多列並行處理
Mpi4py一個非常優秀的特性是完美支援numpy!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
import os, sys, time import numpy as np import mpi4py.MPI as MPI # # Global variables for MPI # # instance for invoking MPI relatedfunctions comm = MPI.COMM_WORLD # the node rank in the whole community comm_rank = comm.Get_rank() # the size of the whole community, i.e.,the total number of working nodes in the MPI cluster comm_size = comm.Get_size() # test MPI if __name__ == "__main__": #create a matrix if comm_rank == 0: all_data = np.arange(20).reshape(4, 5) print "************ data ******************" print all_data #broadcast the data to all processors all_data = comm.bcast(all_data if comm_rank == 0 else None, root = 0) #divide the data to each processor num_samples = all_data.shape[0] local_data_offset = np.linspace(0, num_samples, comm_size + 1).astype('int') #get the local data which will be processed in this processor local_data = all_data[local_data_offset[comm_rank] :local_data_offset[comm_rank + 1]] print "****** %d/%d processor gets local data ****" %(comm_rank, comm_size) print local_data #reduce to get sum of elements local_sum = local_data.sum() all_sum = comm.reduce(local_sum, root = 0, op = MPI.SUM) #process in local local_result = local_data ** 2 #gather the result from all processors and broadcast it result = comm.allgather(local_result) result = np.vstack(result) if comm_rank == 0: print "*** sum: ", all_sum print "************ result ******************" print result |
四、MPI和mpi4py的環境搭建
這章放到這裡是作為一個附錄。我們的環境是linux,需要安裝的包有python、openmpi、numpy、cpython和mpi4py,過程如下:
4.1、安裝Python
1 2 3 4 5 |
#tar xzvf Python-2.7.tgz #cd Python-2.7 #./configure--prefix=/home/work/vis/zouxiaoyi/my_tools #make #make install |
先將Python放到環境變數裡面,還有Python的外掛庫
1 2 |
exportPATH=/home/work/vis/zouxiaoyi/my_tools/bin:$PATH exportPYTHONPATH=/home/work/vis/zouxiaoyi/my_tools/lib/python2.7/site-packages:$PYTHONPATH |
執行#python,如果看到可愛的>>>出來,就表示成功了。按crtl+d退出
4.2、安裝openmpi
1 2 3 4 5 6 |
#wget http://www.open-mpi.org/software/ompi/v1.4/downloads/openmpi-1.4.1.tar.gz #tar xzvf openmpi-1.4.1.tar.gz #cd openmpi-1.4.1 #./configure--prefix=/home/work/vis/zouxiaoyi/my_tools #make -j 8 #make install |
然後把bin路徑加到環境變數裡面:
1 2 |
exportPATH=/home/work/vis/zouxiaoyi/my_tools/bin:$PATH exportLD_LIBRARY_PATH=/home/work/vis/zouxiaoyi/my_tools/lib:$LD_LIBRARY_PATH |
執行#mpirun,如果有幫助資訊列印出來,就表示安裝好了。需要注意的是,我安裝了幾個版本都沒有成功,最後安裝了1.4.1這個版本才能成功,因此就看你的人品了。
4.3、安裝numpy和Cython
安裝python庫的方法可以參考之前的部落格。過程一般如下:
1 2 3 |
#tar –xgvf Cython-0.20.2.tar.gz #cd Cython-0.20.2 #python setup.py install |
開啟Python,import Cython,如果沒有報錯,就表示安裝成功了
4.4、安裝mpi4py
1 2 3 |
#tar –xgvf mpi4py_1.3.1.tar.gz #cd mpi4py #vi mpi.cfg |
在68行,[openmpi]下面,將剛才已經安裝好的openmpi的目錄給改上。
1 2 |
mpi_dir = /home/work/vis/zouxiaoyi/my_tools #python setup.py install |
開啟Python,import mpi4py as MPI,如果沒有報錯,就表示安裝成功了
下面就可以開始屬於你的並行之旅了,勇敢探索多核的樂趣吧。