2011年3月30日 星期三

OpenMP 心得 (三) Parallel Construct

本文將介紹 OpenMP 最基本且最重要的 construct: parallel construct,如果程式中沒有 parallel construct 則程式只能被序列執行。另外文中也將示範 barrier construct 以及 omp_get_thread_num() 函式的用法。

Parallel Construct 是由 parallel directive 與我們想要平行處理的敘述群所組成的程式區塊,只有在此區塊內的程式敘述會被平行執行 (使用多執行緒),區塊外的程式則是序列執行 (使用單一執行緒)。以 fork-join model 來看,Parallel directive 是 fork 的起始點,系統在此處加入其它的執行緒來協助主執行緒一起處理工作,而在 parallel construct 的結尾則進行 join,只留下主執行緒繼續以序列方式執行剩下的工作。整個過程如下圖所示:
從圖中可以看出 parallel directive 只負責新增執行緒,若沒有進一步的指示,則這群執行緒會分別執行平行區塊內相同的程式敘述,如此一來 construct 內的工作實際上並沒有被分工處理,而是在同一時間內被完成了很多次。要將 parallel construct 內的工作分派給個別的執行緒去執行必須要靠 OpenMP 的 work-sharing directives 才行。

使用 parallel directive 的語法如下:
In C/C++
#pragma omp parallel [clause[[,] clause]...]
{
  statement
  ...
}
In Fortran:
!$OMP PARALLEL [clause[[,] clause]...]
       statement
       ...
!$OMP END PARALLEL
Parallel construct 的啟始點由含有 parallel directive 的特殊敘述行開始,在 C/C++ 中必須用小寫的英文字來寫,而在 Fortran 則無大小寫之別。Directive 之後可以依需要加上其它 clauses 來對 parallel construct 做更多的設定。Construct 的結束點在 C/C++ 中通常是代表結構敘述結束的右大括號 "}",而在 Fortran 中則是使用對應的 end directive 敘述做為 construct 的終止點。以下我們示範一個使用 parallel construct 的簡單程式,讓參與運算的執行緒都對我們說 hello:
//--Program: hello_threads.c
//--Example of using OpenMP parallel directive
//--Written by AAZ

#include <stdio.h>
#include <omp.h>

int main(int argc, char **argv)
{
    int thread_id;
    printf("I am the main thread.\n\n");

    #pragma omp parallel private(thread_id)
    {
    thread_id = omp_get_thread_num();

    printf("Thread %d: Hello.\n", thread_id);
    printf("Thread %d: Bye bye.\n", thread_id);
    }

    printf("\nI am the main thread.\n");

    return 0;
}
在程式中我們匯入 omp.h 這個 OpenMP 函式庫,這是因為在接下來的程式中我們會使用到 omp_get_thread_num() 這個函式。在多執行緒的處理程序下系統會為每個執行緒編號以利管理,執行緒編號稱作 Thread ID (TID),由數字 0 開始編起,TID 為 0 的執行緒為該程序的主執行諸 (master thread)。omp_get_thread_num() 函式的作用是回傳目前執行程式的執行緒編號。補充說明一點,所有的 OpenMP 函式都會使用關鍵字 omp 加上底線,即 "omp_",作為開頭以方便識別。

主程式的架構很簡單,就是讓參與程式運算的 threads 印出 hello 以及 bye bye。為了突顯 parallel construct 的 fork-join 效果,在 construct 前後我們都讓主執行緒印出一段訊息以證明 parallel construct 外只有一個執行緒在工作。當建立 parallel construct 時我們加入一個 private clause,宣告在 construct 內的 thread_id 變數為各執行緒所私有的,如此各執行緒執行接下來的程式敘述時才能取用各自的 thread_id 值。確定 OpenMP construct 內的變數為執行緒所共用或私有是非常重要的事,如果沒有設定正確會造成執行結果出現錯誤,有關這個議題會在之後以專文討論,目前只要先記得預設上在某個 OpenMP construct 外宣告的變數都是該 construct 內所有執行緒所共享的 (有一個例外是迴圈的 index,在 OpenMP loop construct 中它是私有的),而在 construct 內宣告的變數則是屬於各執行緒所私有的。如果要將共享變數變成私有變數,則必須在 directive 之後使用 private clause 來宣告,多個私有變數的宣告是在變數之間以逗號區隔,例如
int i, j, k
#pragma omp directive private(i, j, k)
{
...
}
程式 hello_threads.c 的執行預想圖如下:
接下來我們來看程式實際執行的結果。在本系列文章中我們使用雙 CPU 八核心的 Linux 伺服器來作為範例程式的測試平臺,OS 為 CentOS 5.5,使用的 C 編譯器為 GCC 4.1.2。下圖為 hello_threads.c 程式在 bash shell 命令視窗下編譯、運算環境設定、以及執行結果的截圖:
圖中第一行指令是使用 gcc 編譯 OpenMP 程式的指令,其中的重點是要在編譯指令中加入 "-fopenmp" 選項才能正確地完成程式的編譯,在之後的文章我們將不再顯示 C 程式的編譯過程。第二行指令是建立 shell 環境參數 OMP_NUM_THREADS 以設定執行 OpenMP 程式使用的執行緒數目,如果未在程式執行前設定此參數則 shell 會使用系統可用的最大獨立執行緒數目來執行程式。以我們使用的測試平臺為例,最大的獨立執行緒數目為 8。截圖中剩下的內容為程式執行的結果,一開始只有主執行緒會印出訊息,進入平行區域後可以看到有編號 0 到 3 共四個執行緒在執行 parallel construct 內的程式敘述。這邊要注意的是執行緒並未按照其編號順序來執行程式,基本上先完成工作的執行緒會先輸出訊息,因此每次執行此程式的輸出結果都不見得會一樣,不過可以確定的是在 parallel construct 內的敘述一定都會被每個執行緒執行一次,這與我們之前預想的程式執行動作吻合。

以下展示 Fortran 版本的示範程式與執行結果。範例的 Fortran 格式採用 fixed format,使用的編譯器為免費版的 Intel Fortran Compiler 12.0.2。在我們示範的 OpenMP Fortran 程式中有關 OpenMP directive 的宣告都將使用大寫的英文字。完整的程式碼如下:
c--Program: hello_threads.f
c--Example of using OpenMP parallel directive
c--Written by AAZ

      program main
      use omp_lib
      integer thread_id

      write(*,*) 'I am the main thread.'

!$OMP PARALLEL PRIVATE(thread_id)
      thread_id = omp_get_thread_num()

      write(*,*) 'Thread ', thread_id, ': Hello.'
      write(*,*) 'Thread ', thread_id, ': Bye bye.'
!$OMP END PARALLEL 

      write(*,*) 'I am the main thread.'

      stop
      end program main
程式的架構與 hello_thread.c 相同,只有兩個部分要稍微注意,一是在 Fortran 中 OpenMP 的函式庫名稱為 omp_lib,另外一點是 Fortran 的 parallel construct 是以相對應於 parallel directive 的 END PARALLEL 敘述做為 construct 的結束點。下圖為 hello_thread.f 的編譯指令與在四個執行緒環境下執行的結果,可以看出輸出結果與我們設計的動作相符。這邊要提醒一點, Intel Fortran 編譯 OpenMP 程式的選項為 -openmp,如果讀者使用其它的編譯器應先查詢該編譯器有無支援 OpenMP 以及編譯選項的語法。

在介紹完 parallel construct 之後,我們來討論一下執行緒在 parallel construct 內的調用情況與進程。程式平行執行所需要的執行緒數目在 fork 步驟建立之後並非馬上就去執行接下來的程式敘述,而是在提供執行緒的處理器核心有空時才會被系統所調用。一個有趣的實驗是即使在單核心的電腦下我們也能夠執行多執行緒的程式,不過這就像是要一個人去做多人份的工作一樣,並沒有真正達到同時平行處理程式的效果。所以要完善地利用系統資源去平行執行程式則必須要先知道系統可以同時提供的最大獨立執行緒數目,通常 CPU 一個處理核心可以提供一個獨立的執行緒,Intel 的 hyper-threading 技術則宣稱可以提供兩個執行緒。另外在執行平行程式時最好不要用盡所有的 CPU 核心,至少留一個給系統處理其它工作用,否則在程式進行中某個執行緒可能會被系統調用去處理其它程序的任務,造成留下來的執行緒必須在程式中某個同步點等待該執行緒回來後才能繼續進行接下來的工作。如此不但會延遲程式的進展,更重要的是會拖累到整個作業系統的運作。

OpenMP 的程式多少都會存在著讓所有執行緒進程暫停的點,當執行緒執行程式到暫停點時就會進入暫停執行的狀態。如果這個暫停點是用來同步化執行緒的進度,則只有當所有參與平行運算的執行緒都到達這個點後才能結束暫停狀態繼續往下執行程式,在本系列文章中我們稱這樣的暫停點叫「執行緒同步點」。OpenMP 中執行緒同步點分成隱性與顯性兩種,例如 parallel construct 的結束點就是隱性的執行緒同步點,所有的執行緒都會停在此處等到齊之後再進行 join 步驟以離開平行運算區域。另外將在之後介紹的 work-sharing constructs 其結束點也是一個隱性的執行緒同步點。顯性的執行緒同步點可以使用 barrier construct 來設立,barrier construct 是由 barrier directive 所建構,因為它純粹是用來當作一個執行緒的同步點,所以語法非常簡單,只有一行敘述,沒有什麼程式區塊的作用範圍,也不會用到任何的 clauses。使用上只要在程式中想讓執行緒同步的地方加入以下敘述即可完成同步點的設立:
In C/C++
#pragma omp barrier
In Fortran:
!$OMP BARRIER
在程式中設立執行緒同步點是有其需要與功用的,以本文所示範的程式為例,如果我們想要讓所有的執行緒都先說完 Hello 後再說 Bye bye,則只要在印出 Hello 與 Bye bye 的敘述間加入 barrier construct 就能夠做到。以下為原來的 C 語言範例加入 barrier construct 後的程式碼與執行結果,Fortran 的部分就不再示範。
//--Program: hello_threads_barrier.c
//--Example of OpenMP parallel construct with barrier directive
//--Written by AAZ

#include <stdio.h>
#include <omp.h>

int main(int argc, char **argv)
{
    int thread_id;
    printf("I am the main thread.\n\n");

    #pragma omp parallel private(thread_id)
    {
    thread_id = omp_get_thread_num();

    printf("Thread %d: Hello.\n", thread_id);
    #pragma omp barrier
    printf("Thread %d: Bye bye.\n", thread_id);
    }

    printf("\nI am the main thread.\n");

    return 0;
}

本章總結
  1. Parallel directive 只產生新的執行緒但不會指示執行緒該如何分工處理 construct 內的工作。
  2. 如果 parallel construct 內沒有其它 OpenMP work-sharing constructs 去指派執行緒如何分工,則個別的執行緒都會將 construct 內相同的敘述執行一遍。
  3. 在 directive 之後加上 private clause 可以設定 construct 內那些變數是屬於執行緒所私有的。
  4. 設定環境變數 OMP_NUM_THREADS 的值可以決定程式執行時參與平行運算的執行緒數量。
  5. 程式中要使用 OpenMP 函式前必須先匯入 OpenMP 的函式庫,在 C/C++ 中函式庫的名稱為 omp.h,而在 Fortran 為 omp_lib。所有的 OpenMP 函式都以 "omp_" 作為函式名稱的開頭。
  6. 系統會為程序中的執行緒編號,其中編號為 0 的執行緒為該程序的主執行緒。另外在平行區域中執行程式的順序與其編號大小無關,執行緒的調用與執行先後是依處理核心的工作狀態來決定。
  7. 使用 omp_get_thread_num 函式可以取得目前工作的執行緒編號。
  8. Parallel construct 與 work-sharing constructs 的結束點是一個隱性的執行緒同步點。而顯性的同步點可以使用 barrier construct 來設立。
在了解多執行緒是如何被產生之後,隨之而來的課題就是要學習如何利用這些執行緒去平行處理程式中的任務,這項工作在 OpenMP 中是由 work-sharing constructs 所負責。下一篇文章我們將先介紹屬於 work-sharing constructs 一員的 loop construct,說明如何使用它來平行運算迴圈結構內的工作。

(發佈日期:2011/03/30 by AAZ)

沒有留言:

張貼留言