OpenMP Parallel Construct 實現原理與原始碼分析

一無是處的研究僧發表於2023-01-25

OpenMP Parallel Construct 實現原理與原始碼分析

前言

在本篇文章當中我們將主要分析 OpenMP 當中的 parallel construct 具體時如何實現的,以及這個 construct 呼叫了哪些執行時庫函式,並且詳細分析這期間的引數傳遞!

Parallel 分析——編譯器角度

在本小節當中我們將從編譯器的角度去分析該如何處理 parallel construct 。首先從詞法分析和語法分析的角度來說這對編譯器並不難,只需要加上一些處理規則,關鍵是編譯器將一個 parallel construct 具體編譯成了什麼?

下面是一個非常簡單的 parallel construct。

#pragma omp parallel
{
  body;
}

編譯器在遇到上面的 parallel construct 之後會將程式碼編譯成下面的樣子:

void subfunction (void *data)
{
  use data;
  body;
}

setup data;
GOMP_parallel_start (subfunction, &data, num_threads);
subfunction (&data);
GOMP_parallel_end ();

首先 parallel construct 中的程式碼塊會被編譯成一個函式 sub function,當然了函式名不一定是這個,然後會在使用 #pragma omp parallel 的函式當中將一個 parallel construct 編譯成 OpenMP 動態庫函式的呼叫,在上面的虛擬碼當中也指出了,具體會呼叫 OpenMP 的兩個庫函式 GOMP_parallel_start 和 GOMP_parallel_end ,並且主執行緒也會呼叫函式 subfunction ,我們在後面的文章當中在仔細分析這兩個動態庫函式的原始碼。

深入剖析 Parallel 動態庫函式引數傳遞

動態庫函式分析

在本小節當中,我們主要去分析一下在 OpenMP 當中共享引數是如何傳遞的,以及介紹函式 GOMP_parallel_start 的幾個引數的含義。

首先我們分析函式 GOMP_parallel_start 的引數含義,這個函式的函式原型如下:

void GOMP_parallel_start (void (*fn)(void *), void *data, unsigned num_threads)

上面這個函式一共有三個引數:

  • 第一個引數 fn 是一個函式指標,主要是用於指向上面編譯出來的 subfunction 這個函式的,因為需要多個執行緒同時執行這個函式,因此需要將這個函式傳遞過去,讓不同的執行緒執行。
  • 第二個引數是傳遞的資料,我們在並行域當中會使用到共享的或者私有的資料,這個指標主要是用於傳遞資料的,我們在後面會仔細分析這個引數的使用。
  • 第三個引數是表示 num_threads 子句指定的執行緒個數,如果不指定這個子句預設的引數是 0 ,但是如果你使用了 IF 子句並且條件是 false 的話,那麼這個引數的值就是 1 。
  • 這個函式的主要作用是啟動一個或者多個執行緒,並且執行函式 fn 。
void GOMP_parallel_end (void)
  • 這個函式的主要作用是進行執行緒的同步,因為一個 parallel 並行域需要等待所有的執行緒都執行完成之後才繼續往後執行。除此之外還需要釋放執行緒組的資源並行返回到之前的 omp_in_parallel() 表示的狀態。

引數傳遞分析

我們現在使用下面的程式碼來具體分析引數傳遞過程:

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

int main()
{
  int data = 100;
  int two  = -100;
  printf("start\n");
#pragma omp parallel num_threads(4) default(none) shared(data, two)
  {
    printf("tid = %d data = %d two = %d\n", omp_get_thread_num(), data, two);
  }

  printf("finished\n");
  return 0;
}

我們首先來分析一下上面的兩個變數 data 和 two 的是如何被傳遞的,我們首先用圖的方式進行表示,然後分析一下彙編程式並且對圖進行驗證。

上面的程式碼當中兩個變數 datatwo 在記憶體當中的佈局結構大致如下所示(假設 data 的初始位置時 0x0):

那麼在函式 GOMP_parallel_start 當中傳遞的引數 data 就是 0x0 也就是指向 data 的記憶體地址,如下圖所示:

那麼根據上面引數傳遞的情況,我們就可以在 subfunction 當中使用 *(int*)data 得到 data 的值,使用 *((int*) ((char*)data + 4)) 得到 two 的值,如果是 private 傳遞的話我們就可以先複製這個資料再使用,如果是 shared 的話,那麼我們就可以直接使用指標就行啦。

上面的程式我們用 pthread 大致描述一下,則 pthread 對應的程式碼如下所示:


#include "pthread.h"
#include "stdio.h"
#include "stdint.h"

typedef struct data_in_main_function{
    int data;
    int two;
}data_in_main_function;

pthread_t threads[4];

void* subfunction(void* data)
{
  int two = ((data_in_main_function*)data)->two;
  int data_ = ((data_in_main_function*)data)->data;
  printf("tid = %ld data = %d two = %d\n", pthread_self(), data_, two);
  return NULL;
}

int main()
{
  // 在主函式申請 8 個位元組的棧空間
  data_in_main_function data;
  data.data = 100;
  data.two = -100;
  for(int i = 0; i < 4; ++i)
  {
    pthread_create(&threads[i], NULL, subfunction, &data);
  }
  for(int i = 0; i < 4; ++i)
  {
    pthread_join(threads[i], NULL);
  }
  return 0;
}

彙編程式分析

在本節當中我們將仔細去分析上面的程式所產生的彙編程式,在本文當中的彙編程式基礎 x86_64 平臺。在分析彙編程式之前我們首先需要了解一下 x86函式的呼叫規約,具體來說就是在進行函式呼叫的時候哪些暫存器儲存函式引數以及是第幾個函式引數。具體的規則如下所示:

暫存器 含義
rdi 第一個引數
rsi 第二個引數
rdx 第三個引數
rcx 第四個引數
r8 第五個引數
r9 第六個引數

我們現在仔細分析一下上面的程式的 main 函式的反彙編程式:

00000000004006cd <main>:
  4006cd:       55                      push   %rbp
  4006ce:       48 89 e5                mov    %rsp,%rbp
  4006d1:       48 83 ec 10             sub    $0x10,%rsp
  4006d5:       c7 45 fc 64 00 00 00    movl   $0x64,-0x4(%rbp)
  4006dc:       c7 45 f8 9c ff ff ff    movl   $0xffffff9c,-0x8(%rbp)
  4006e3:       bf f4 07 40 00          mov    $0x4007f4,%edi
  4006e8:       e8 93 fe ff ff          callq  400580 <puts@plt>
  4006ed:       8b 45 fc                mov    -0x4(%rbp),%eax
  4006f0:       89 45 f0                mov    %eax,-0x10(%rbp)
  4006f3:       8b 45 f8                mov    -0x8(%rbp),%eax
  4006f6:       89 45 f4                mov    %eax,-0xc(%rbp)
  4006f9:       48 8d 45 f0             lea    -0x10(%rbp),%rax
  4006fd:       ba 04 00 00 00          mov    $0x4,%edx
  400702:       48 89 c6                mov    %rax,%rsi
  400705:       bf 3d 07 40 00          mov    $0x40073d,%edi
  40070a:       e8 61 fe ff ff          callq  400570 <GOMP_parallel_start@plt>
  40070f:       48 8d 45 f0             lea    -0x10(%rbp),%rax
  400713:       48 89 c7                mov    %rax,%rdi
  400716:       e8 22 00 00 00          callq  40073d <main._omp_fn.0>
  40071b:       e8 70 fe ff ff          callq  400590 <GOMP_parallel_end@plt>
  400720:       8b 45 f0                mov    -0x10(%rbp),%eax
  400723:       89 45 fc                mov    %eax,-0x4(%rbp)
  400726:       8b 45 f4                mov    -0xc(%rbp),%eax
  400729:       89 45 f8                mov    %eax,-0x8(%rbp)
  40072c:       bf fa 07 40 00          mov    $0x4007fa,%edi
  400731:       e8 4a fe ff ff          callq  400580 <puts@plt>
  400736:       b8 00 00 00 00          mov    $0x0,%eax
  40073b:       c9                      leaveq 
  40073c:       c3                      retq   

從上面的反彙編程式我們可以看到在主函式的彙編程式碼當中確實呼叫了函式 GOMP_parallel_start 和 GOMP_parallel_end,並且 subfunction 為 main._omp_fn.0 ,它對應的彙編程式如下所示:

000000000040073d <main._omp_fn.0>:
  40073d:       55                      push   %rbp
  40073e:       48 89 e5                mov    %rsp,%rbp
  400741:       48 83 ec 10             sub    $0x10,%rsp
  400745:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
  400749:       e8 52 fe ff ff          callq  4005a0 <omp_get_thread_num@plt>
  40074e:       48 8b 55 f8             mov    -0x8(%rbp),%rdx
  400752:       8b 4a 04                mov    0x4(%rdx),%ecx
  400755:       48 8b 55 f8             mov    -0x8(%rbp),%rdx
  400759:       8b 12                   mov    (%rdx),%edx
  40075b:       89 c6                   mov    %eax,%esi
  40075d:       bf 03 08 40 00          mov    $0x400803,%edi
  400762:       b8 00 00 00 00          mov    $0x0,%eax
  400767:       e8 44 fe ff ff          callq  4005b0 <printf@plt>
  40076c:       c9                      leaveq 
  40076d:       c3                      retq   
  40076e:       66 90                   xchg   %ax,%ax

GOMP_parallel_start 詳細引數分析

  • void (*fn)(void *), 我們現在來看一下函式 GOMP_parallel_start 的第一個引數,根據我們前面談到的第一個引數應該儲存在 rdi 暫存器,我們現在分析一下在 main 函式的反彙編程式當中在呼叫函式 GOMP_parallel_start 之前 rdi 暫存器的值。我們可以看到在 main 函式位置為 4006f8 的地方的指令 mov $0x40073d,%edi 可以看到 rdi 暫存器的值為 0x40073d (edi 暫存器是 rdi 暫存器的低 32 位),我們可以看到 函式 main._omp_fn.0 的起始地址就是 0x40073d ,因此我們就可以在函式 GOMP_parallel_start 使用這個函式指標了,最終在啟動的執行緒當中呼叫這個函式。

  • void *data,這是函式 GOMP_parallel_start 的第二個引數,根據前面的分析第二個引數儲存在 rsi 暫存器當中,我現在將 main 數當中和 rsi 相關的指令選擇出來:

00000000004006cd <main>:
  4006cd:       55                      push   %rbp
  4006ce:       48 89 e5                mov    %rsp,%rbp
  4006d1:       48 83 ec 10             sub    $0x10,%rsp
  4006d5:       c7 45 fc 64 00 00 00    movl   $0x64,-0x4(%rbp)
  4006dc:       c7 45 f8 9c ff ff ff    movl   $0xffffff9c,-0x8(%rbp)
  4006ed:       8b 45 fc                mov    -0x4(%rbp),%eax
  4006f0:       89 45 f0                mov    %eax,-0x10(%rbp)
  4006f3:       8b 45 f8                mov    -0x8(%rbp),%eax
  4006f6:       89 45 f4                mov    %eax,-0xc(%rbp)
  4006f9:       48 8d 45 f0             lea    -0x10(%rbp),%rax
  400702:       48 89 c6                mov    %rax,%rsi

上面的彙編程式的棧空間以及在呼叫函式之前 GOMP_parallel_start 部分暫存器的指向如下所示:

最終在呼叫函式 GOMP_parallel_start 之前 rsi 暫存器的指向如上圖所示,上圖當中 rsi 的指向的記憶體地址作為引數傳遞過去。根據上文談到的 subfunction 中的引數可以知道,在函式 main._omp_fn.0 當中的 rdi 暫存器(也就是第一個引數 *data)的值就是上圖當中 rsi 暫存器指向的記憶體地址的值(事實上也就是 rsi 暫存器的值)。大家可以自行對照著函式 main._omp_fn.0 的彙編程式對 rdi 暫存器的使用就可以知道這其中的引數傳遞的過程了。

  • unsigned num_threads,根據前文提到的儲存第三個引數的暫存器是 rdx,在 main 函式的位置 4006fd 處,指令為 mov $0x4,%edx,這和我們自己寫的程式是一致的都是 4 (0x4)。

動態庫函式原始碼分析

GOMP_parallel_start 原始碼分析

我們首先來看一下函式 GOMP_parallel_start 的原始碼:

void
GOMP_parallel_start (void (*fn) (void *), void *data, unsigned num_threads)
{
  num_threads = gomp_resolve_num_threads (num_threads, 0);
  gomp_team_start (fn, data, num_threads, gomp_new_team (num_threads));
}

在這裡我們對函式 gomp_team_start 進行分析,其他兩個函式 gomp_resolve_num_threads 和 gomp_new_team 只簡單進行作用說明,太細緻的原始碼分析其實是沒有必要的,感興趣的同學自行分析即可,我們只需要瞭解整個執行流程即可。

  • gomp_resolve_num_threads,這個函式的主要作用是最終確定需要幾個執行緒去執行任務,因為我們可能並沒有使用 num_threads 子句,而且這個值和環境變數也有關係,因此需要對執行緒的個數進行確定。
  • gomp_new_team,這個函式的主要作用是建立包含 num_threads 個執行緒資料的執行緒組,並且對資料進行初始化操作。
  • gomp_team_start,這個函式的主要作用是啟動 num_threads 個執行緒去執行函式 fn ,這其中涉及一些細節,比如說執行緒的親和性(affinity)設定。

由於 gomp_team_start 的原始碼太長了,這裡只是節選部分源程式進行分析:

  /* Launch new threads.  */
  for (; i < nthreads; ++i, ++start_data)
    {
      pthread_t pt;
      int err;

      start_data->fn = fn; // 這行程式碼就是將 subfunction 函式指標進行儲存最終在函式  gomp_thread_start 當中進行呼叫
      start_data->fn_data = data; // 這裡儲存函式 subfunction 的函式引數
      start_data->ts.team = team; // 執行緒的所屬組
      start_data->ts.work_share = &team->work_shares[0];
      start_data->ts.last_work_share = NULL;
      start_data->ts.team_id = i; // 執行緒的 id 我們可以使用函式 omp_get_thread_num 得到這個值
      start_data->ts.level = team->prev_ts.level + 1;
      start_data->ts.active_level = thr->ts.active_level;
#ifdef HAVE_SYNC_BUILTINS
      start_data->ts.single_count = 0;
#endif
      start_data->ts.static_trip = 0;
      start_data->task = &team->implicit_task[i];
      gomp_init_task (start_data->task, task, icv);
      team->implicit_task[i].icv.nthreads_var = nthreads_var;
      start_data->thread_pool = pool;
      start_data->nested = nested;
			// 如果使用了執行緒的親和性那麼還需要進行親和性設定
      if (gomp_cpu_affinity != NULL)
	gomp_init_thread_affinity (attr);

      err = pthread_create (&pt, attr, gomp_thread_start, start_data);
      if (err != 0)
	gomp_fatal ("Thread creation failed: %s", strerror (err));
    }

上面的程式就是最終啟動執行緒的源程式,可以看到這是一個 for 迴圈並且啟動 nthreads 個執行緒,pthread_create 是真正建立了執行緒的程式碼,並且讓執行緒執行函式 gomp_thread_start 可以看到執行緒不是直接執行 subfunction 而是將這個函式指標儲存到 start_data 當中,並且在函式 gomp_thread_start 真正去呼叫這個函式,看到這裡大家應該明白了整個 parallel construct 的整個流程了。

gomp_thread_start 的函式題也相對比較長,在這裡我們選中其中的比較重要的幾行程式碼,其餘的程式碼進行省略。對比上面執行緒啟動的 pthread_create 語句我們可以知道,下面的程式真正的呼叫了 subfunction,並且給這個函式傳遞了對應的引數。

static void *
gomp_thread_start (void *xdata)
{
  struct gomp_thread_start_data *data = xdata;
  /* Extract what we need from data.  */
  local_fn = data->fn;
  local_data = data->fn_data;
  local_fn (local_data);
  return NULL;
}

GOMP_parallel_end 分析

這個函式的主要作用就是一個同步點,保證所有的執行緒都執行完成之後再繼續往後執行,這一部分的原始碼比較雜,其核心原理就是使用路障 barrier 去實現的,這其中是 OpenMP 自己實現的一個 barrier 而不是直接使用 pthread 當中的 barrier ,這一部分的源程式就不進行仔細分析了,感興趣的同學可以自行閱讀,可以參考 OpenMP 鎖實現原理

總結

在本篇文章當中主要給大家介紹了 parallel construct 的實現原理,以及他的動態庫函式的呼叫以及原始碼分析,大家只需要瞭解整個流程不太需要死扣細節(這並無很大的用處)只有當我們自己需要去實現 OpenMP 的時候需要去了解這些細節,不然我們只需要瞭解整個動態庫的設計原理即可!


更多精彩內容合集可訪問專案:https://github.com/Chang-LeHung/CSCore

關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演算法與資料結構)知識。

相關文章