使用 Emscripten 將 Pthread 轉成 JavaScript 與效能分析 (2) — Merge Sort
- 2020-08-10
- Liu, An-Chi 劉安齊
¶ 1. 簡介
本文接續前篇「使用 Emscripten 將 Pthread 轉成 JavaScript 與效能分析」,對用 Emscripten 將 Pthread 轉 JS 做另一個 Case 的測試,這次將檢驗 Merge Sort 的表現。
¶ 2. Merge Sort
Merge Sort 複雜度原本是 $\Theta(nlogn)$,如果將 Recursion 的部分做平行化之後可以縮減到 $\Theta(n)$,若是合併 Sort 也平行化可以優化到 $\Theta({n \over (log n)^2})$。
(source: Wikipedia )
¶ 2.1 Pthread 版本 Merge Sort
以下 Merge Sort 的 Pthread 範例 C++ Code 來自 Geeksforgeeks,這個範例 Code 僅將 Recursion 做平行化,合併 Sort 的部分為單緒執行。我已經將註解都翻譯成中文。
merge_sort.cc
:
#include <iostream>
#include <pthread.h>
#include <time.h>
// 陣列元素數量
#define MAX 10000
// 幾個 threads
#define THREAD_MAX 4
using namespace std;
// 用來算 merge sort 的陣列
int arr[MAX];
int part = 0;
// 用來合併兩個已經排好的部份的函數
void merge(int low, int mid, int high)
{
// 暫時儲存左邊部分和右邊部分的陣列
int* left = new int[mid - low + 1];
int* right = new int[high - mid];
// n1 是左邊部分的長度,n2 是右邊部分的長度
int n1 = mid - low + 1, n2 = high - mid, i, j;
// 將左邊部分從 arr 複製到 left
for (i = 0; i < n1; i++)
left[i] = arr[i + low];
// 將右邊部分從 arr 複製到 right
for (i = 0; i < n2; i++)
right[i] = arr[i + mid + 1];
int k = low;
i = j = 0;
// 從左到右遞升合併進 arr
while (i < n1 && j < n2) {
if (left[i] <= right[j])
arr[k++] = left[i++];
else
arr[k++] = right[j++];
}
// 插入左邊剩下的部分
while (i < n1) {
arr[k++] = left[i++];
}
// 插入右邊剩下的部分
while (j < n2) {
arr[k++] = right[j++];
}
}
// merge sort 函數
void merge_sort(int low, int high)
{
// 取得陣列中位數
int mid = low + (high - low) / 2;
if (low < high) {
// 遞迴左半邊
merge_sort(low, mid);
// 遞迴右半邊
merge_sort(mid + 1, high);
// 合併
merge(low, mid, high);
}
}
// thread 用的函數
void* merge_sort(void* arg)
{
// 4 個 thread 的哪一個
int thread_part = part++;
// 每個 thread 的範圍
int low = thread_part * (MAX / 4);
int high = (thread_part + 1) * (MAX / 4) - 1;
int mid = low + (high - low) / 2;
if (low < high) {
merge_sort(low, mid);
merge_sort(mid + 1, high);
merge(low, mid, high);
}
}
// 起始點
int main()
{
// 隨便填入數值
for (int i = 0; i < MAX; i++)
arr[i] = rand() % 100;
// t1 and t2 用來計算時間,分別是開始和結束
clock_t t1, t2;
t1 = clock();
pthread_t threads[THREAD_MAX];
// 建立 4 個 threads
for (int i = 0; i < THREAD_MAX; i++)
pthread_create(&threads[i], NULL, merge_sort, (void*)NULL);
// Join 4 個 Thread
for (int i = 0; i < 4; i++)
pthread_join(threads[i], NULL);
// 將 4 個部分的結果合併
merge(0, (MAX / 2 - 1) / 2, MAX / 2 - 1);
merge(MAX / 2, MAX/2 + (MAX-1-MAX/2)/2, MAX - 1);
merge(0, (MAX - 1)/2, MAX - 1);
t2 = clock();
// 列印結果,註解掉防止 I/O 造成負擔
/*
cout << "Sorted array: ";
for (int i = 0; i < MAX; i++)
cout << arr[i] << " ";
*/
// 列印執行時間
cout << "Time taken: " << (t2 - t1) /
(double)CLOCKS_PER_SEC << endl;
return 0;
}
執行這段 code:
$ g++ merge_sort.cc -lpthread
$ ./a.out
¶ 2.2 Pthread 轉 JavaScript 版本 Merge Sort
Emscripten 的介紹和使用方法請看前篇。本文將進行兩個實驗,一個是原本的 main()
執行在 JS 的 Main Thread,另一個則是將 main()
改成由一個 Worker 驅動。
後者在 Emscripten 中稱為 Proxy 模式,之所以要這樣是因為 JS 的 Main Thread 通常還要負責網頁的 UI 和 Event。如果沒有用 Proxy,照一般 Pthread 的邏輯,Main Thread 只要出現 pthread_join
或是有上 Lock 的地方,Main Thread 就會直接卡住,不僅畫面會卡住,瀏覽器還可能跳出網頁沒有回應的警告,這在網頁設計中是很致命的。
如果要用 Proxy 模式,要設定 PROXY_TO_PTHREAD=1
,此外因為我們 Merge Sort 的陣列可能會很大,要設定 ALLOW_MEMORY_GROWTH=1
突破記憶體預設限制。
Proxy 模式:
$ em++ merge_sort.cc \
-s USE_PTHREADS=1 \
-s PROXY_TO_PTHREAD=1 \
-s PTHREAD_POOL_SIZE=4 \
-s ALLOW_MEMORY_GROWTH=1 \
-O3 \
-o merge_sort_proxy.html
一般模式:
$ em++ merge_sort.cc \
-s USE_PTHREADS=1 \
-s PTHREAD_POOL_SIZE=4 \
-s ALLOW_MEMORY_GROWTH=1 \
-O3 \
-o merge_sort_no_proxy.html
¶ 2.3 JavaScript 版本 Merge Sort
依照 C++ 版本邏輯,我重新刻了一個 JavaScript 版本。
差別在於 arr
原本是全域變數,現在必須使用 SharedArrayBuffer。
然後 Join 的部分改成 Worker 用 Message 去通知,並用 SharedArrayBuffer 和 Atomics 來實現 Join 的 Barrier (一次只能加 1,加到 4 代表所有 Worker 都做完)。
此外這邊用到一個小技巧,因為 C++ 中 int 型別除法會自動捨棄小數,但 JS 中所有數值都是 Float64,所以要將小樹捨棄,速度最快的作法是 |0
,例如 5/2 | 0
會得到 2
。
以下儲存成一個 HTML 檔案,直接用瀏覽器打開就能跑了:
<!-- merge-sort.html -->
<div id="content"></div>
<script id="worker" type="app/worker">
let arr;
// merge function for merging two parts
function merge(low, mid, high) {
const left = new Array(mid - low + 1);
const right = new Array(high - mid);
const n1 = mid - low + 1;
const n2 = high - mid;
let i, j;
for (i = 0; i < n1; i++)
left[i] = arr[i + low];
for (i = 0; i < n2; i++)
right[i] = arr[i + mid + 1];
let k = low;
i = j = 0;
while (i < n1 && j < n2) {
if (left[i] <= right[j])
arr[k++] = left[i++];
else
arr[k++] = right[j++];
}
while (i < n1) {
arr[k++] = left[i++];
}
while (j < n2) {
arr[k++] = right[j++];
}
}
function merge_sort(low, high) {
const mid = (low + (high - low) / 2 | 0 );
if (low < high) {
merge_sort(low, mid);
merge_sort(mid + 1, high);
merge(low, mid, high);
}
}
addEventListener('message', function (e) {
const low = e.data.low;
const high = e.data.high;
arr = new Int32Array(e.data.buf);
const mid = (low + (high - low) / 2) | 0;
if (low < high) {
merge_sort(low, mid);
merge_sort(mid + 1, high);
merge(low, mid, high);
}
postMessage("done");
}, false);
</script>
<script>
function merge(low, mid, high) {
console.log(arr)
const left = new Array(mid - low + 1);
const right = new Array(high - mid);
const n1 = mid - low + 1;
const n2 = high - mid;
let i, j;
for (i = 0; i < n1; i++)
left[i] = arr[i + low];
for (i = 0; i < n2; i++)
right[i] = arr[i + mid + 1];
let k = low;
i = j = 0;
while (i < n1 && j < n2) {
if (left[i] <= right[j])
arr[k++] = left[i++];
else
arr[k++] = right[j++];
}
while (i < n1) {
arr[k++] = left[i++];
}
while (j < n2) {
arr[k++] = right[j++];
}
}
const first_time = (new Date()).getTime();
const thread = 4;
const arrMax = 1e5;
const buf = new SharedArrayBuffer(arrMax * Int32Array.BYTES_PER_ELEMENT);
const arr = new Int32Array(buf);
for (const i in arr) {
arr[i] = Math.random() * 10000 | 0;
}
document.getElementById("content").innerHTML = "origin: " + arr + "<br/>";
const barrierBuf = new SharedArrayBuffer(1 * Int32Array.BYTES_PER_ELEMENT);
const barrier = new Int32Array(barrierBuf);
for (let i = 0; i < thread; i++) {
const blob = new Blob([document.querySelector('#worker').textContent]);
const url = window.URL.createObjectURL(blob);
const worker = new Worker(url);
const low = i * (arrMax / thread);
const high = (i + 1) * (arrMax / thread) - 1;
worker.postMessage({
low: low,
high: high,
buf: buf,
});
worker.onmessage = e => {
if (e.data == "done") {
Atomics.add(barrier, 0, 1);
if (Atomics.load(barrier, 0) === thread) {
merge(0, ((arrMax / 2 | 0) - 1) / 2 | 0, (arrMax / 2 | 0) - 1);
merge(arrMax / 2, arrMax / 2 | 0 +
(arrMax - 1 - (arrMax / 2 | 0) | 0 / 2) | 0, arrMax - 1);
merge(0, (arrMax - 1) / 2 | 0, arrMax - 1);
const last_time = (new Date()).getTime();
const content = "new: " + arr + "<br/>" +
"time: " + (last_time - first_time) / 1000 + "s"
document.getElementById("content").innerHTML += content;
}
}
};
}
</script>
¶ 3. 效能分析
效能實驗分別測試 (1) Pthread 用 Emscripten Proxy 模式 O3 轉 JS + WASM (2) Pthread 用Emscripten 一般模式 O3 轉 JS + WASM (3) Pthread g++ O3 (4) Pthread g++ O2 (5) Chrome 跑純 JavaScript 版本,這幾種情況下效能差異。實驗環境為 AMD Ryzen 7 2700X 3.7 GHz 八核處理器 (只開 4 執行緒),emcc v1.39、g++ v7.5、Chrome 84。
| Array Length | Em Proxy | Em No Proxy | C++ O3 | C++ O2 | JS |
|—|—|—|—|—|—|—|
| 100000 | 0.056s | 0.062s | 0.027s | 0.032s | 0.214s |
| 10000 | 0.031s | 0.009s | 0.002s | 0.003s | 0.052s |
| 1000 | 0.029s | 0.002s | 0.001s | 0.001s | 0.023s |
將上表時間取 log 並反轉可以得到下圖。同個長度下,不同方法之間越高代表越快。不同長度,同個方法下,越高代表越快。
Pthread 不意外是最快的,不過 O3 和 O2 相差沒很多,這個結果跟前篇文章 PI 的結果大相逕庭。
有個滿特別的現象是,不管長度如何,不同方法的 1e3 和 1e4 之間相差很少,不像 1e4 到 1e5 這樣劇烈,推測是長度在 1e4 以下 Overheads 會發生在其他地方。
看起來 Emscripten Proxy 模式在陣列數量小的時候 Overheads 比較大,在 1e3 和 1e4 都輸給 Emscripten 一般模式不少,不過在數量達 1e5 的時候,Proxy 模式反而跑得比一般模式快。
最後 JS 版本不管長度如何都輸給其他方法,不過這也很合理,畢竟不管是 C++ 或 WASM 本來就會比 JS 快。不過 1e3 和 1e4 的時候輸比較少,到了 1e5 的時候 JS 直接慢 C++ 8x 和慢 WASM 6x。
¶ 4. 結論
本文實驗 Pthread、Pthread 轉 JS + WASM 和純 JS,執行 Merge Sort 的效能。結果 Merge Sort 以 Pthread 版本跑最快,Emscripten 轉換的 WASM 其次,其中又以一般模式在陣列長度小時比較快,而 Proxy 模式在陣列長度大時有優勢,最後純 JS 的版本執行速度最慢。