OpenMP 原子指令設計與實現

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

OpenMP 原子指令設計與實現

前言

在本篇文章當中主要與大家分享一下 openmp 當中的原子指令 atomic,分析 #pragma omp atomic 在背後究竟做了什麼,編譯器是如何處理這條指令的。

為什麼需要原子指令

加入現在有兩個執行緒分別執行在 CPU0 和 CPU1,如果這兩個執行緒都要對同一個共享變數進行更新操作,就會產生競爭條件。如果沒有保護機制來避免這種競爭,可能會導致結果錯誤或者程式崩潰。原子指令就是解決這個問題的一種解決方案,它能夠保證操作的原子性,即操作不會被打斷或者更改。這樣就能保證在多執行緒環境下更新共享變數的正確性。

比如在下面的圖當中,兩個執行緒分別在 CPU0 和 CPU1 執行 data++ 語句,如果目前主存當中的 data = 1 ,然後按照圖中的順序去執行,那麼主存當中的 data 的最終值等於 2 ,但是這並不是我們想要的結果,因為有兩次加法操作我們希望最終在記憶體當中的 data 的值等於 3 ,那麼有什麼方法能夠保證一個執行緒在執行 data++ 操作的時候下面的三步操作是原子的嘛(不可以分割):

  • Load data : 從主存當中將 data 載入到 cpu 的快取。
  • data++ : 執行 data + 1 操作。
  • Store data : 將 data 的值寫回主存。

事實上硬體就給我們提供了這種機制,比如 x86 的 lock 指令,在這裡我們先不去討論這一點,我們將在後文當中對此進行仔細的分析。

OpenMP 原子指令

在 openmp 當中 #pragma omp atomic 的表示式格式如下所示:

#pragma omp atomic
表示式;

其中表示式可以是一下幾種形式:

x binop = 表示式;
x++;
x--;
++x;
--x;

二元運算子 binop 為++, --, +, -, *, /, &, ^, | , >>, <<或 || ,x 是基本資料型別 int,short,long,float 等資料型別。

我們現在來使用一個例子熟悉一下上面鎖談到的語法:



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

int main()
{
  int data = 1;
#pragma omp parallel num_threads(4) shared(data) default(none)
  {
#pragma omp atomic
    data += data * 2;
  }
  printf("data = %d\n", data);
  return 0;
}

上面的程式最終的輸出結果如下:

data = 81

上面的 data += data * 2 ,相當於每次操作將 data 的值擴大三倍,因此最終的結果就是 81 。

原子操作和鎖的區別

OpenMP 中的 atomic 指令允許執行無鎖操作,而不會影響其他執行緒的並行執行。這是透過在硬體層面上實現原子性完成的。鎖則是透過軟體來實現的,它阻塞了其他執行緒對共享資源的訪問。

在選擇使用 atomic 或鎖時,應該考慮操作的複雜性和頻率。對於簡單的操作和高頻率的操作,atomic 更加高效,因為它不會影響其他執行緒的並行執行。但是,對於複雜的操作或者需要多個操作來完成一個任務,鎖可能更加合適。

原子操作只能夠進行一些簡單的操作,如果操作複雜的是沒有原子指令進行操作的,這一點我們在後文當中詳細談到,如果你想要原子性的是一個程式碼塊的只能夠使用鎖,而使用不了原子指令。

深入剖析原子指令——從彙編角度

加法和減法原子操作

我們現在來仔細分析一下下面的程式碼的彙編指令,看看編譯器在背後為我們做了些什麼:


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

int main()
{
  int data = 0;
#pragma omp parallel num_threads(4) shared(data) default(none)
  {
#pragma omp atomic
    data += 1;
  }
  printf("data = %d\n", data);
  return 0;
}

首先我們需要了解一點編譯器會將並行域的程式碼編譯成一個函式,我們現在看看上面的 parallel 並行域的對應的函式的的彙編程式:

0000000000401193 <main._omp_fn.0>:
  401193:       55                      push   %rbp
  401194:       48 89 e5                mov    %rsp,%rbp
  401197:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
  40119b:       48 8b 45 f8             mov    -0x8(%rbp),%rax
  40119f:       48 8b 00                mov    (%rax),%rax
  4011a2:       f0 83 00 01             lock addl $0x1,(%rax) # 這就是編譯出來的原子指令——對應x86平臺
  4011a6:       5d                      pop    %rbp
  4011a7:       c3                      retq   
  4011a8:       0f 1f 84 00 00 00 00    nopl   0x0(%rax,%rax,1)
  4011af:       00 

在上面的彙編程式碼當中最終的一條指令就是 lock addl $0x1,(%rax),這條指令便是編譯器在編譯 #pragma omp atomic 的時候將 data += 1 轉化成硬體的對應的指令。我們可以注意到和普通的加法指令的區別就是這條指令前面有一個 lock ,這是告訴硬體在指令 lock 後面的指令的時候需要保證指令的原子性。

以上就是在 x86 平臺下加法操作對應的原子指令。我們現在將上面的 data += 1,改成 data -= 1,在來看一下它對應的彙編程式:

0000000000401193 <main._omp_fn.0>:
  401193:       55                      push   %rbp
  401194:       48 89 e5                mov    %rsp,%rbp
  401197:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
  40119b:       48 8b 45 f8             mov    -0x8(%rbp),%rax
  40119f:       48 8b 00                mov    (%rax),%rax
  4011a2:       f0 83 28 01             lock subl $0x1,(%rax)
  4011a6:       5d                      pop    %rbp
  4011a7:       c3                      retq   
  4011a8:       0f 1f 84 00 00 00 00    nopl   0x0(%rax,%rax,1)
  4011af:       00 

可以看到它和加法指令的主要區別就是 addl 和 subl,其他的程式是一樣的。

乘法和除法原子操作

我們現在將下面的程式進行編譯:



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

int main()
{
  int data = 1;
#pragma omp parallel num_threads(4) shared(data) default(none)
  {
#pragma omp atomic
    data *= 2;
  }
  printf("data = %d\n", data);
  return 0;
}

上面程式碼的並行域被編譯之後的彙編程式如下所示:

0000000000401193 <main._omp_fn.0>:
  401193:       55                      push   %rbp
  401194:       48 89 e5                mov    %rsp,%rbp
  401197:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
  40119b:       48 8b 45 f8             mov    -0x8(%rbp),%rax
  40119f:       48 8b 08                mov    (%rax),%rcx
  4011a2:       8b 01                   mov    (%rcx),%eax
  4011a4:       89 c2                   mov    %eax,%edx
  4011a6:       8d 34 12                lea    (%rdx,%rdx,1),%esi # 這條語句的含義為 data *= 2
  4011a9:       89 d0                   mov    %edx,%eax
  4011ab:       f0 0f b1 31             lock cmpxchg %esi,(%rcx)
  4011af:       89 d6                   mov    %edx,%esi
  4011b1:       89 c2                   mov    %eax,%edx
  4011b3:       39 f0                   cmp    %esi,%eax
  4011b5:       75 ef                   jne    4011a6 <main._omp_fn.0+0x13>
  4011b7:       5d                      pop    %rbp
  4011b8:       c3                      retq   
  4011b9:       0f 1f 80 00 00 00 00    nopl   0x0(%rax)

我們先不仔細去分析上面的彙編程式,我們先來看一下上面程式的行為:

  • 首先載入 data 的值,儲存為 temp,這個 temp 的值儲存在暫存器當中。
  • 然後將 temp 的值乘以 2 儲存在暫存器當中。
  • 最後比較 temp 的值是否等於 data,如果等於那麼就將 data 的值變成 temp ,如果不相等(也就是說有其他執行緒更改了 data 的值,此時不能賦值給 data)回到第一步,這個操作主要是基於指令 cmpxchg

上面的三個步驟當中第三步是一個原子操作對應上面的彙編指令 lock cmpxchg %esi,(%rcx) ,cmpxchg 指令前面加了 lock 主要是儲存這條 cmpxchg 指令的原子性。

如果我們將上面的彙編程式使用 C 語言重寫的話,那麼就是下面的程式那樣:



#include <stdio.h>
#include <stdbool.h>
#include <stdatomic.h>

// 這個函式對應上面的彙編程式
void atomic_multiply(int* data)
{
  int oldval = *data;
  int write = oldval * 2;
  // __atomic_compare_exchange_n 這個函式的作用就是
  // 將 data 指向的值和 old 的值進行比較,如果相等就將 write 的值寫入 data
  // 指向的記憶體地址 如果操作成功返回 true 否則返回 false
  while (!__atomic_compare_exchange_n (data, &oldval, write, false,
                                      __ATOMIC_ACQUIRE, __ATOMIC_RELAXED))
  {
    oldval = *data;
    write = oldval * 2;
  }
}

int main()
{
  int data = 2;
  atomic_multiply(&data);
  printf("data = %d\n", data);
  return 0;
}

現在我們在來仔細分析一下上面的彙編程式,首先我們需要仔細瞭解一下 cmpxchg 指令,這個指令在上面的彙編程式當中的作用是比較 eax 暫存器和 rcx 暫存器指向的記憶體地址的資料,如果相等就將 esi 暫存器的值寫入到 rcx 指向的記憶體地址,如果不想等就將 rcx 暫存器指向的記憶體的值寫入到 eax 暫存器。

透過理解上面的指令,在 cmpxchg 指令之後的就是檢視是否 esi 暫存器的值寫入到了 rcx 暫存器指向的記憶體地址,如果是則不執行跳轉語句,否則指令回到位置 4011a6 重新執行,這就是一個 while 迴圈。

我們在來看一下將乘法變成除法之後的彙編指令:

0000000000401193 <main._omp_fn.0>:
  401193:       55                      push   %rbp
  401194:       48 89 e5                mov    %rsp,%rbp
  401197:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
  40119b:       48 8b 45 f8             mov    -0x8(%rbp),%rax
  40119f:       48 8b 08                mov    (%rax),%rcx
  4011a2:       8b 01                   mov    (%rcx),%eax
  4011a4:       89 c2                   mov    %eax,%edx
  4011a6:       89 d0                   mov    %edx,%eax
  4011a8:       c1 e8 1f                shr    $0x1f,%eax
  4011ab:       01 d0                   add    %edx,%eax
  4011ad:       d1 f8                   sar    %eax
  4011af:       89 c6                   mov    %eax,%esi
  4011b1:       89 d0                   mov    %edx,%eax
  4011b3:       f0 0f b1 31             lock cmpxchg %esi,(%rcx)
  4011b7:       89 d6                   mov    %edx,%esi
  4011b9:       89 c2                   mov    %eax,%edx
  4011bb:       39 f0                   cmp    %esi,%eax
  4011bd:       75 e7                   jne    4011a6 <main._omp_fn.0+0x13>
  4011bf:       5d                      pop    %rbp
  4011c0:       c3                      retq   
  4011c1:       66 2e 0f 1f 84 00 00    nopw   %cs:0x0(%rax,%rax,1)
  4011c8:       00 00 00 
  4011cb:       0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)

從上面的彙編程式碼當中的 cmpxchg 和 jne 指令可以看出除法操作使用的還是比較並交換指令(CAS) cmpxchg,並且也是使用 while 迴圈。

其實複雜的表示式都是使用這個方式實現的:while 迴圈 + cmpxchg 指令,我們就不一一的將其他的使用方式也拿出來一一解釋了。簡單的表示式可以直接使用 lock + 具體的指令實現。

總結

在本篇文章當中主要是深入剖析了 OpenMP 當中各種原子指令的實現原理以及分析了他們對應的彙編程式,OpenMP 在處理 #pragma omp atomic 的時候如果能夠使用原子指令完成需求那就直接使用原子指令,否則的話就使用 CAS cmpxchg 指令和 while 迴圈完成對應的需求。


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

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

相關文章