如何實現前端高性能計算?

最近做一個項目,里面涉及到在前端做大量計算,直接用js跑了一下,大概需要15s的時間, 也就是用戶的瀏覽器會卡死15s,這個完全接受不了。

雖說有V8這樣牛逼的引擎,但大家知道js并不適合做CPU密集型的計算,一是因為單線程,二是因為動態語言。我們就從這兩個突破口入手,首先搞定“單線程”的限制,嘗試用WebWorkers來加速計算。

前端高性能計算之一:WebWorkers 什么是WebWorkers

簡單說, WebWorkers 是一個HTML5的新API,web開發者可以通過此API在后臺運行一個腳本而不阻塞UI,可以用來做需要大量計算的事情,充分利用CPU多核。

大家可以看看這篇文章介紹https://www.html5rocks.com/en/tutorials/workers/basics/, 或者 對應的中文版

引用

The Web Workers specification defines an API for spawning background scripts in your web application. Web Workers allow you to do things like fire up long-running scripts to handle computationally intensive tasks, but without blocking the UI or other scripts to handle user interactions.

可以打開 這個鏈接 自己體驗一下WebWorkers的加速效果。

現在瀏覽器基本都 支持WebWorkers 了。

Parallel.js

直接使用 WebWorkers 接口還是太繁瑣,好在有人已經對此作了封裝: Parallel.js

注意 Parallel.js 可以通過node安裝:

$ npm install paralleljs

不過這個是在node.js下用的,用的node的cluster模塊。如果要在瀏覽器里使用的話, 需要直接應用js:

<script src="parallel.js"></script>

然后可以得到一個全局變量,Parallel。Parallel提供了map和reduce兩個函數式編程的接口,可以非常方便的進行并發操作。

我們先來定義一下我們的問題,由于業務比較復雜,我這里把問題簡化成求1-1,0000,0000的和,然后在依次減去1-1,0000,0000,答案顯而易見: 0! 這樣做是因為數字太大的話會有數據精度的問題,兩種方法的結果會有一些差異,會讓人覺得并行的方法不可靠。此問題在我的mac pro chrome61下直接簡單地跑js運行的話大概是1.5s(我們實際業務問題需要15s,這里為了避免用戶測試的時候把瀏覽器搞死,我們簡化了問題)。

const N = 100000000;// 總次數1億

// 更新自2017-10-24 16:47:00
// 代碼沒有任何含義,純粹是為了模擬一個耗時計算,直接用
//   for (let i = start; i <= end; i += 1) total += i;
// 有幾個問題,一是代碼太簡單沒有任何稍微復雜一點的操作,后面用C代碼優化的時候會優化得很夸張,沒法對比。
// 二是數據溢出問題, 我懶得處理這個問題,下面代碼簡單地先加起來,然后再減掉,答案顯而易見為0,便于測試。
function sum(start, end) {
  let total = 0;
  for (let i = start; i <= end; i += 1) {
    if (i % 2 == 0 || i % 3 == 1) {
      total += i;
    } else if (i % 5 == 0 || i % 7 == 1) {
      total += i / 2;
    }
  }
  for (let i = start; i <= end; i += 1) {
    if (i % 2 == 0 || i % 3 == 1) {
      total -= i;
    } else if (i % 5 == 0 || i % 7 == 1) {
      total -= i / 2;
    }
  }

  return total;
}

function paraSum(N) {
  const N1 = N / 10;//我們分成10分,沒分分別交給一個web worker,parallel.js會根據電腦的CPU核數建立適量的workers
  let p = new Parallel([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
    .require(sum);
  return p.map(n => sum((n - 1) * 10000000 + 1, n * 10000000))// 在parallel.js里面沒法直接應用外部變量N1
    .reduce(data => {
      const acc = data[0];
      const e = data[1];
      return acc + e;
    });
}

export { N, sum, paraSum }

代碼比較簡單,我這里說幾個剛用的時候遇到的坑。

require所有需要的函數

比如在上訴代碼中用到了sum,你需要提前require(sum),如果sum中由用到了另一個函數f,你還需要require(f),同樣如果f中用到了g,則還需要require(g),直到你require了所有用到的定義的函數。。。。

沒法require變量

我們上訴代碼我本來定義了N1,但是沒法用

ES6編譯成ES5之后的問題以及Chrome沒報錯

實際項目中一開始我們用到了ES6的特性:數組解構。本來這是很簡單的特性,現在大部分瀏覽器都已經支持了,不過我當時配置的babel會編譯成ES5,所以會生成代碼_slicedToArray,大家可以在線上Babel測試,然后Chrome下面始終不work,也沒有任何報錯信息,查了很久,后來在Firefox下打開,有報錯信息:

ReferenceError: _slicedToArray is not defined

看來Chrome也不是萬能的啊。。。

大家可以在 此Demo頁面 測試, 提速大概在4倍左右,當然還是得看自己電腦CPU的核數。 另外我后來在同樣的電腦上Firefox55.0.3(64位)測試,上訴代碼居然只要190ms!!!在Safari9.1.1下也是190ms左右。。。

Refers

前端高性能計算之二:asm.js & webassembly

前面我們說了要解決高性能計算的兩個方法,一個是并發用WebWorkers,另一個就是用更底層的靜態語言。

2012年,Mozilla的工程師 Alon Zakai 在研究 LLVM 編譯器時突發奇想:能不能把C/C++編譯成Javascript,并且盡量達到Native代碼的速度呢?于是他開發了 Emscripten 編譯器,用于將C/C++代碼編譯成Javascript的一個子集 asm.js ,性能差不多是原生代碼的50%。大家可以看看這個PPT。

之后Google開發了[Portable Native Client][PNaCI],也是一種能讓瀏覽器運行C/C++代碼的技術。 后來估計大家都覺得各搞各的不行啊,居然Google, Microsoft, Mozilla, Apple等幾家大公司一起合作開發了一個面向Web的通用二進制和文本格式的項目,那就是 WebAssembly ,官網上的介紹是:

引用

WebAssembly or wasm is a new portable, size- and load-time-efficient format suitable for compilation to the web.

所以,WebAssembly應該是一個前景很好的項目。我們可以看一下 目前瀏覽器的支持情況

安裝Emscripten

訪問https://kripken.github.io/emscripten-site/docs/getting_started/downloads.html

1. 下載對應平臺版本的SDK

2. 通過emsdk獲取最新版工具

 

bash
  # Fetch the latest registry of available tools.
  ./emsdk update

  # Download and install the latest SDK tools.
  ./emsdk install latest

  # Make the "latest" SDK "active" for the current user. (writes ~/.emscripten file)
  ./emsdk activate latest

  # Activate PATH and other environment variables in the current terminal
  source ./emsdk_env.sh

3. 將下列添加到環境變量PATH中

~/emsdk-portable
~/emsdk-portable/clang/fastcomp/build_incoming_64/bin
~/emsdk-portable/emscripten/incoming

4. 其他

我在執行的時候碰到報錯說LLVM版本不對,后來參考文檔配置了LLVM_ROOT變量就好了,如果你沒有遇到問題,可以忽略。

LLVM_ROOT = os.path.expanduser(os.getenv('LLVM', '/home/ubuntu/a-path/emscripten-fastcomp/build/bin'))

5. 驗證是否安裝好

執行emcc -v,如果安裝好會出現如下信息:

emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 1.37.21
clang version 4.0.0 (https://github.com/kripken/emscripten-fastcomp-clang.git 974b55fd84ca447c4297fc3b00cefb6394571d18) (https://github.com/kripken/emscripten-fastcomp.git 9e4ee9a67c3b67239bd1438e31263e2e86653db5) (emscripten 1.37.21 : 1.37.21)
Target: x86_64-apple-darwin15.5.0
Thread model: posix
InstalledDir: /Users/magicly/emsdk-portable/clang/fastcomp/build_incoming_64/bin
INFO:root:(Emscripten: Running sanity checks)

Hello, WebAssembly!

創建一個文件hello.c:

#include <stdio.h>
int main() {
  printf("Hello, WebAssembly!\n");
  return 0;
}

編譯C/C++代碼:

emcc hello.c

上述命令會生成一個a.out.js文件,我們可以直接用Node.js執行:

node a.out.js

輸出:

Hello, WebAssembly!

為了讓代碼運行在網頁里面,執行下面命令會生成hello.html和hello.js兩個文件,其中hello.js和a.out.js內容是完全一樣的。

emcc hello.c -o hello.html
?  webasm-study md5 a.out.js
MD5 (a.out.js) = d7397f44f817526a4d0f94bc85e46429
?  webasm-study md5 hello.js
MD5 (hello.js) = d7397f44f817526a4d0f94bc85e46429

然后在瀏覽器打開hello.html,可以看到頁面:;;

前面生成的代碼都是asm.js,畢竟Emscripten是人家作者Alon Zakai最早用來生成asm.js的,默認輸出asm.js也就不足為奇了。當然,可以通過option生成wasm,會生成三個文件:hello-wasm.html, hello-wasm.js, hello-wasm.wasm。

emcc hello.c -s WASM=1 -o hello-wasm.html

然后瀏覽器打開hello-wasm.html,發現報錯TypeError: Failed to fetch。原因是wasm文件是通過XHR異步加載的,用file:////訪問會報錯,所以我們需要啟一個服務器。

npm install -g serve
serve .

然后訪問http://localhost:5000/hello-wasm.html,就可以看到正常結果了。

調用C/C++函數

前面的Hello, WebAssembly!都是main函數直接打出來的,而我們使用WebAssembly的目的是為了高性能計算,做法多半是用C/C++實現某個函數進行耗時的計算,然后編譯成wasm,暴露給js去調用。

在文件add.c中寫如下代碼:

#include <stdio.h>
int add(int a, int b) {
  return a + b;
}

int main() {
  printf("a + b: %d", add(1, 2));
  return 0;
}

有兩種方法可以把add方法暴露出來給js調用。

通過命令行參數暴露API

emcc -s EXPORTED_FUNCTIONS="['_add']" add.c -o add.js

注意方法名add前必須加_。 然后我們可以在Node.js里面這樣使用:

// file node-add.js
const add_module = require('./add.js');
console.log(add_module.ccall('add', 'number', ['number', 'number'], [2, 3]));

執行node node-add.js會輸出5。如果需要在web頁面使用的話,執行:

emcc -s EXPORTED_FUNCTIONS="['_add']" add.c -o add.html

然后在生成的add.html中加入如下代碼:

<button onclick="nativeAdd()">click</button>
  <script type='text/javascript'>
    function nativeAdd() {
      const result = Module.ccall('add', 'number', ['number', 'number'], [2, 3]);
      alert(result);
    }
  </script>

然后點擊button,就可以看到執行結果了。

Module.ccall會直接調用C/C++代碼的方法,更通用的場景是我們獲取到一個包裝過的函數,可以在js里面反復調用,這需要用Module.cwrap,具體細節可以參看 文檔

const cAdd = add_module.cwrap('add', 'number', ['number', 'number']);
console.log(cAdd(2, 3));
console.log(cAdd(2, 4));

定義函數的時候添加EMSCRIPTEN_KEEPALIVE

添加文件add2.c。

#include <stdio.h>
#include <emscripten.h>

int EMSCRIPTEN_KEEPALIVE add(int a, int b) {
  return a + b;
}

int main() {
  printf("a + b: %d", add(1, 2));
  return 0;
}

執行命令:

emcc add2.c -o add2.html

同樣在add2.html中添加代碼:

<button onclick="nativeAdd()">click</button>
  <script type='text/javascript'>
    function nativeAdd() {
      const result = Module.ccall('add', 'number', ['number', 'number'], [2, 3]);
      alert(result);
    }
  </script>

但是,當你點擊button的時候,報錯:

Assertion failed: the runtime was exited (use NO_EXIT_RUNTIME to keep it alive after main() exits)

可以通過在main()中添加emscripten_exit_with_live_runtime()解決:

#include <stdio.h>
#include <emscripten.h>

int EMSCRIPTEN_KEEPALIVE add(int a, int b) {
  return a + b;
}

int main() {
  printf("a + b: %d", add(1, 2));
  emscripten_exit_with_live_runtime();
  return 0;
}

或者也可以直接在命令行中添加-s NO_EXIT_RUNTIME=1來解決,

emcc add2.c -o add2.js -s NO_EXIT_RUNTIME=1

不過會報一個警告:

exit(0) implicitly called by end of main(), but noExitRuntime, so not exiting the runtime (you can use emscripten_force_exit, if you want to force a true shutdown)

所以建議采用第一種方法。

上述生成的代碼都是asm.js,只需要在編譯參數中添加-s WASM=1中就可以生成wasm,然后使用方法都一樣。

用asm.js和WebAssembly執行耗時計算

前面準備工作都做完了, 現在我們來試一下用C代碼來優化前一篇中提過的問題。代碼很簡單:

// file sum.c
#include <stdio.h>
// #include <emscripten.h>

long sum(long start, long end) {
  long total = 0;
  for (long i = start; i <= end; i += 3) {
    total += i;
  }
  for (long i = start; i <= end; i += 3) {
    total -= i;
  }
  return total;
}

int main() {
  printf("sum(0, 1000000000): %ld", sum(0, 1000000000));
  // emscripten_exit_with_live_runtime();
  return 0;
}

注意用gcc編譯的時候需要把跟emscriten相關的兩行代碼注釋掉,否則編譯不過。 我們先直接用gcc編譯成native code看看代碼運行多塊呢?

?  webasm-study gcc sum.c
?  webasm-study time ./a.out
sum(0, 1000000000): 0./a.out  5.70s user 0.02s system 99% cpu 5.746 total
?  webasm-study gcc -O1 sum.c
?  webasm-study time ./a.out
sum(0, 1000000000): 0./a.out  0.00s user 0.00s system 64% cpu 0.003 total
?  webasm-study gcc -O2 sum.c
?  webasm-study time ./a.out
sum(0, 1000000000): 0./a.out  0.00s user 0.00s system 64% cpu 0.003 total

可以看到有沒有優化差別還是很大的,優化過的代碼執行時間是3ms!。really?仔細想想,我for循環了10億次啊,每次for執行大概是兩次加法,兩次賦值,一次比較,而我總共做了兩次for循環,也就是說至少是100億次操作,而我的mac pro是2.5 GHz Intel Core i7,所以1s應該也就執行25億次CPU指令操作吧,怎么可能逆天到這種程度,肯定是哪里錯了。想起之前看到的 一篇rust測試性能的文章 ,說rust直接在編譯的時候算出了答案, 然后把結果直接寫到了編譯出來的代碼里, 不知道gcc是不是也做了類似的事情。在知乎上 GCC中-O1 -O2 -O3 優化的原理是什么? 這篇文章里, 還真有loop-invariant code motion(LICM)針對for的優化,所以我把代碼增加了一些if判斷,希望能“糊弄”得了gcc的優化。

#include <stdio.h>
// #include <emscripten.h>

// long EMSCRIPTEN_KEEPALIVE sum(long start, long end) {
long sum(long start, long end) {
  long total = 0;
  for (long i = start; i <= end; i += 1) {
    if (i % 2 == 0 || i % 3 == 1) {
      total += i;
    } else if (i % 5 == 0 || i % 7 == 1) {
      total += i / 2;
    }
  }
  for (long i = start; i <= end; i += 1) {
    if (i % 2 == 0 || i % 3 == 1) {
      total -= i;
    } else if (i % 5 == 0 || i % 7 == 1) {
      total -= i / 2;
    }
  }
  return total;
}

int main() {
  printf("sum(0, 1000000000): %ld", sum(0, 100000000));
  // emscripten_exit_with_live_runtime();
  return 0;
}

執行結果大概要正常一些了。

?  webasm-study gcc -O2 sum.c
?  webasm-study time ./a.out
sum(0, 1000000000): 0./a.out  0.32s user 0.00s system 99% cpu 0.324 total

ok,我們來編譯成asm.js了。

#include <stdio.h>
#include <emscripten.h>

long EMSCRIPTEN_KEEPALIVE sum(long start, long end) {
// long sum(long start, long end) {
  long total = 0;
  for (long i = start; i <= end; i += 1) {
    if (i % 2 == 0 || i % 3 == 1) {
      total += i;
    } else if (i % 5 == 0 || i % 7 == 1) {
      total += i / 2;
    }
  }
  for (long i = start; i <= end; i += 1) {
    if (i % 2 == 0 || i % 3 == 1) {
      total -= i;
    } else if (i % 5 == 0 || i % 7 == 1) {
      total -= i / 2;
    }
  }
  return total;
}

int main() {
  printf("sum(0, 1000000000): %ld", sum(0, 100000000));
  emscripten_exit_with_live_runtime();
  return 0;
}

執行:

emcc sum.c -o sum.html

然后在sum.html中添加代碼

<button onclick="nativeSum()">NativeSum</button>
  <button onclick="jsSumCalc()">JSSum</button>
  <script type='text/javascript'>
    function nativeSum() {
      t1 = Date.now();
      const result = Module.ccall('sum', 'number', ['number', 'number'], [0, 100000000]);
      t2 = Date.now();
      console.log(`result: ${result}, cost time: ${t2 - t1}`);
    }
  </script>
  <script type='text/javascript'>
    function jsSum(start, end) {
      let total = 0;
      for (let i = start; i <= end; i += 1) {
        if (i % 2 == 0 || i % 3 == 1) {
          total += i;
        } else if (i % 5 == 0 || i % 7 == 1) {
          total += i / 2;
        }
      }
      for (let i = start; i <= end; i += 1) {
        if (i % 2 == 0 || i % 3 == 1) {
          total -= i;
        } else if (i % 5 == 0 || i % 7 == 1) {
          total -= i / 2;
        }
      }

      return total;
    }
    function jsSumCalc() {
      const N = 100000000;// 總次數1億
      t1 = Date.now();
      result = jsSum(0, N);
      t2 = Date.now();
      console.log(`result: ${result}, cost time: ${t2 - t1}`);
    }
  </script>

另外,我們修改成編譯成WebAssembly看看效果呢?

emcc sum.c -o sum.js -s WASM=1
Browser webassembly asm.js js
Chrome61 1300ms 600ms 3300ms
Firefox55 600ms 800ms 700ms
Safari9.1 不支持 2800ms 因不支持ES6我懶得改寫沒測試

感覺Firefox有點不合理啊, 默認的JS太強了吧。然后覺得webassembly也沒有特別強啊,突然發現emcc編譯的時候沒有指定優化選項-O2。再來一次:

emcc -O2 sum.c -o sum.js # for asm.js
emcc -O2 sum.c -o sum.js -s WASM=1 # for webassembly
Browser webassembly -O2 asm.js -O2 js
Chrome61 1300ms 600ms 3300ms
Firefox55 650ms 630ms 700ms

居然沒什么變化, 大失所望。號稱asm.js可以達到native的50%速度么,這個倒是好像達到了。但是今年 Compiling for the Web with WebAssembly (Google I/O '17) 里說WebAssembly是1.2x slower than native code,感覺不對呢。 asm.js 還有一個好處是,它就是js,所以即使瀏覽器不支持,也能當成不同的js執行,只是沒有加速效果。當然 WebAssembly 受到各大廠商一致推崇,作為一個新的標準,肯定前景會更好,期待會有更好的表現。

Refers

人工智能是最近兩年絕對的熱點,而這次人工智能的復興,有一個很重要的原因就是計算能力的提升,主要依賴于GPU。去年Nvidia的股價飆升了幾倍,市面上好點的GPU一般都買不到,因為全被做深度學習以及挖比特幣的人買光了

 

來自:http://www.iteye.com/news/32773

 

 本文由用戶 愛情de懸崖 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!