作者:delenzhang,騰訊CDG前端開發工程師
| 導語最近業餘時間給Electron做了一個c++ addons的node子產品,好奇語言鄙視鍊頂端的c/c艹 的到底比nodejs快多少,于是寫了一個demo測試一下,結果發現...
衆所周知, nodejs 運作引擎是使用c++編寫的免費開源 JavaScript 和 WebAssembly 引擎v8 engine。而 c++ addons 為nodejs開發者提供了一種無中間商賺差價的方式使用 C/C++ 的能力。 先看一下官方文檔的介紹
Addons are dynamically-linked shared objects written in C++. The [`require()`](https://nodejs.org/dist/latest-v18.x/docs/api/modules.html#requireid) function can load addons as ordinary Node.js modules. Addons provide an interface between JavaScript and C/C++ libraries.
c++ 插件是用 C++ 編寫的動态連結共享對象。 require() 函數可以像普通的 Node.js 子產品一樣加載插件。 Addons 提供了 JavaScript 和 C/C++ 庫之間的接口。
那麼使用c++ addons是否能把nodejs的邏輯重寫後,是否能大幅度地提高性能?
開始撰寫demo
先使用node-gyp編譯一個 node子產品,代碼如下:
// calculate.cc
#include <node.h>
namespace calculate {
using v8::Number;
void Method(const FunctionCallbackInfo<Value>&args) {
Isolate* isolate = args.GetIsolate();
// 核心耗時邏輯
int value = args[0].As<Number>()->Value();
int i;
double x = 100.734659, y = 353.2313423432;
for (i=0; i < value; i++) {
x += y;
}
}
void Initialize(Local<Object> exports) {
NODE_SET_METHOD(exports, "calc", Method);
}
NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)
}
代碼比較簡單,這裡我沒用一些比較耗時間的算法 如 計算素數或者 斐波拉切計算,而是最簡單的多次浮點數加法運作來測。考慮到JS沒有單獨的浮點型,浮點數與整數都是通過 Number 類型表示,是遵循 IEEE754标準 的 64 位雙精度值。
為了盡可能地考慮到 控制變量法 的實驗思想,c++ 裡也是使用double。 這裡使用的同類的nodejs檔案直接一個檔案下搞定,内容如下
// calculate.js
const calculate = require('./build/Release/calculate')
// 核心雙精度浮點數計算邏輯
function calc(n) {
let i, x = 100.734659, y=353.2313423432;
for (i=0; i<n; i++) {
x += y;
}
return x
}
console.log('c++ addon result vs Node result')
let keyCpp = 'c++ useTime:'
console.time(keyCpp)
calculate.calc(10000)
console.timeEnd(keyCpp)
let key = "nodejs useTime:"
console.time(key)
calc(10000)
console.timeEnd(key)
下面可以直接運作 node calculate.js 得到結果
測試
先上結果
c++ addon result vs Node result
c++ useTime:: 0.097ms
nodejs useTime:: 0.4ms
好家夥, 這速度杠杠的,直接提升 312% 倍。還是c艹牛,性能真是杠杠的,好了,散了吧。
...
不對,我怎麼知道我結果是對的呢,萬一是錯的呢,要不把結果傳遞出來對比下。
//calculate.cc
...
void Method(const FunctionCallbackInfo<Value>&args) {
Isolate* isolate = args.GetIsolate();
// 核心耗時邏輯
int value = args[0].As<Number>()->Value();
int i;
double x = 100.734659, y = 353.2313423432;
for (i=0; i < value; i++) {
x += y;
}
// 新增導出結果
auto total = Number::New(isolate, x);
args.GetReturnValue().Set(total);
}
...
測試代碼新增
// calculate.js
...
console.time(keyCpp)
console.log("c++ 計算結果:", calculate.calc(10000))
console.timeEnd(keyCpp)
console.time(key)
console.log("nodejs 計算結果:", calc(10000))
console.timeEnd(key)
...
再跑一下 node calculate.js 看下
c++ addon result vs Node result
c++ useTime:: 0.15ms
nodejs useTime:: 0.552ms
c++ 計算結果: 3532414.1580905635
c++ useTime:: 0.513ms
nodejs 計算結果: 3532414.1580905635
nodejs useTime:: 0.402ms
what's your problem? 運作結果差距這麼大,我就導出了資料而已,nodejs 完勝了c++ addons 了。簡單的浮點數傳遞竟然如此消耗性能 。
你幹嘛!
遇事不決,chatgpt。
哦,這樣,chatgpt 好像給了解釋,又好像什麼都沒說。再問下bing
國外的開發者這種探索精神确實很贊,很早就有了同樣的疑問并給出了結論。 這裡隻是簡單的跨底層引擎資料傳遞就消耗了足夠大的性能,就算底層c++對性能進行了高達300%的性能優化,也抵不過一次的資料值傳遞。
這裡 補充一下@johnche大佬給的測試建議,畢竟double類型的c++資料傳遞到js裡v8需要使用HeapNumber, v8内js的數字分為兩種類型,分别是sim和HeapNumber。Smi,小整數,顧名思義用來表示一個小範圍内的整數類型: -(230) ~ 230 - 1 範圍内的整數,我們知道Int32類型的範圍是 -(231) ~ 231 - 1, 為什麼Smi類型會比Int32小呢,這是因為在V8中,Sim類型的值是根據它的位址直接得出的,為了區分Smi類型和普通的指針,Smi類型都存儲在最低位為0的位址中,是以Smi的範圍實際上是Int31類型的範圍。
與Sim對應,HeapNumber則用來表示無法用Smi表示其他Number類型,包括有小數點的數值, 超過Smi範圍的整數, Number.NaN, Infinity等任何不能用Smi表示的Number類型。HeapNumber在V8内部是一個對象,儲存在堆記憶體上,它的名字也展現出了這一點。HeapNumber類型的值是不可變,如果要修改,會建立一個新的HeapNumber并指派。
難道是是因為是浮點數導緻HeapNumber的new導緻了巨大的性能損耗。于是代碼修改如下進行測試:
calculate.cc
int value = args[0].As<Number>()->Value();
int i;
int x = 200, y = 353;
for (i=0; i < value; i++) {
x += y;
}
// auto total = Number::New(isolate, x);
args.GetReturnValue().Set(x);
calculate.js
// 核心雙精度浮點數計算邏輯
function calc(n) {
let i, x = 200, y=353;
for (i=0; i<n; i++) {
x += y;
}
return x
}
// let times = 1
console.log('c++ addon result vs Node result')
let keyCpp = 'c++ useTime:'
console.time(keyCpp)
calculate.calc(10000)
console.timeEnd(keyCpp)
let key = "nodejs useTime:"
console.time(key)
calc(10000)
console.timeEnd(key)
console.time(keyCpp)
console.log("c++ 計算結果:", calculate.calc(10000))
console.timeEnd(keyCpp)
測試結果:
c++ addon result vs Node result
c++ useTime:: 0.083ms
nodejs useTime:: 0.254ms
c++ 計算結果: 3530200
c++ useTime:: 0.663ms
nodejs 計算結果: 3530200
nodejs useTime:: 0.258ms
這裡可以看出c++提升的性能優化依然被值拷貝給損耗了,不過可以看出c++到js的值傳遞其實已經是很快了,基本是0.5ms左右的耗時,如果使用c++對嚴重cpu耗時的程式進行優化,這部分的性能折損也是完全可以接收到。
總結一下:
- 涉及值傳遞的,如果nodejs有大量的邏輯運算損耗大量的時間,c++優化的時間能超過值傳遞帶來的損耗,c++ 優于nodejs, 否則 nodejs優于c++
- 不涉及值傳遞的,大部分情況下c++優于nodejs
我們目前一些流行架構都能找到同樣的問題,比如被大衆诟病的 ReactNative的性能問題,又何嘗不是一次次的js和native的消息傳遞的消耗。 比如小程式的setData每次都是性能優化的重頭之重。比如 jsbridge的調用,每次開發都要注意不要頻繁地調用,引發性能問題。
繼續回歸到c++ addons的優化,如果不進行值的傳遞,隻是調用c++原生功能作為處理即可,是否能彌補nodejs的性能短闆呢,為背景部署高并發的nodejs 伺服器做一下性能優化,這裡把測試代碼改成如下形式來模拟一下多次執行 。
// calculate.js
...
let time = 100000
console.log(\`------ 以下模拟并發運作${time}次--------\`)
console.time(keyCpp)
for (let i = 0; i < time; i++) {
calculate.calc(10000)
}
console.timeEnd(keyCpp)
console.time(key)
for (let i = 0; i < time; i++) {
calc(10000)
}
console.timeEnd(key)
運作結果:
c++ addon result vs Node result
c++ useTime:: 0.091ms
nodejs useTime:: 1.02ms
c++ 計算結果: 3532414.1580905635
c++ useTime:: 0.4ms
nodejs 計算結果: 3532414.1580905635
nodejs useTime:: 0.308ms
------ 以下模拟并發運作100000次--------
c++ useTime:: 1.079s
nodejs useTime:: 1.038s
多次運作測試發現耗時相差不大,約等于相同, 并沒有前面隻運作一次性能優化巨大,這裡可以看出v8 engine 對這一部分應該進行了很大的優化提升,這裡就不得不提一下v8 内聯緩存,這裡其實就是v8 通過内聯緩存來提升函數執行效率。
這裡以範例為例說一下v8的内聯緩存及其原理:
- 函數 calc 在一個 for 循環裡面被重複執行了很多次,是以 V8 會想盡一切辦法來壓縮這個查找過程,以提升對象的查找效率。這個加速函數執行的政策就是内聯緩存 (Inline Cache),簡稱為 IC;
- IC 的原理:在 V8 執行函數的過程中,會觀察函數中一些調用點 (CallSite) 上的關鍵中間資料,然後将這些資料緩存起來,當下次再次執行該函數的時候,V8 就可以直接利用這些中間資料,節省了再次擷取這些資料的過程,是以 V8 利用 IC,可以有效提升一些重複代碼的執行效率。
- IC 會為每個函數維護一個回報向量 (FeedBack Vector),回報向量記錄了函數在執行過程中的一些關鍵的中間資料。
- 回報向量其實就是一個表結構,它由很多項組成的,每一項稱為一個插槽 (Slot),V8 會依次将執行 calc 函數的中間資料寫入到回報向量的插槽中。
- 當 V8 再次調用 calc 函數時,比如執行到 calc 函數中的 return x語句時,它就會在對應的插槽中查找 x 屬性的偏移量,之後 V8 就能直接去記憶體中擷取 x 的值了。這樣就大大提升了 V8 的執行效率。
V8 引入了内聯緩存(IC),IC 會監聽每個函數的執行過程,并在一些關鍵的地方埋下監聽點,這些包括了加載對象屬性 (Load)、給對象屬性指派 (Store)、還有函數調用 (Call),V8 會将監聽到的資料寫入一個稱為回報向量 (FeedBack Vector) 的結構中,同時 V8 會為每個執行的函數維護一個回報向量。有了回報向量緩存的臨時資料,V8 就可以縮短對象屬性的查找路徑,進而提升執行效率。(PS: v8 engine 内部的js執行的優化和實作,這裡隻是一些皮毛,很值得大家去進一步學習和鑽研)
此外,es6也提供的一些高性能的資料集合 如 Set, Map 等,同樣也是在v8 engine内部進行過極緻的性能優化,如果單純地使用 c++的 unordered_set、unordered_map 替換也不一定能達到肉眼可見的性能優化。 國外的小夥伴已經做過測試了,這裡直接引入一下實驗結果:
Javascript 的 Set 性能甚至成為赢家,這是我們唯一看到純 Javascript 在相當高的 N 中戰勝 C++ 插件的情況。是以如果你想使用一些資料結構,你最好使用原生 ES6 資料結構。
文章位址會放到最後,有興趣的小夥伴可以自己嘗試一下。
是以對于使用 c++ addons的性能優化真的不是簡單重寫一遍那麼簡單,還需要考慮到很多問題,比如是否有資料之間的傳遞,對于這種值類型的資料傳遞,各個引擎需要自己重新配置設定空間,這種消耗無疑是巨大的,以及是否在v8 engine下已經做好了優化的資料結構或者操作優化,c++ addons帶來的性能提升的價值遠遠抵消不了開發便利性的損耗。 那什麼時候适合使用c++ addons來替換nodejs呢?nodejs/node-addon-api 維護者 NickNaso是這麼說到
you can improve performance specially for CPU bound operations (think about at image processing).
并提供了 以下幾個範例說明c++ addons 提升性能的使用場景, 有興趣的同學可以自己試一試:
Pure JavaScript | Native add-on |
bcryptjs | bcrypt |
jimp | sharp |
crc64-ecma182.js | crc64-ecma182 |
回歸到上面的測試用例,如果我們要使用 c++ addons進行優化,就需要将多次循環執行的運算一起放到c++ 裡進行
let time = 100000
for (let i = 0; i < time; i++) {
calculate.calc(10000)
}
改成
int time = 10000
for (int j = 0; j < time; j++){
for (i=0; i < value; i++) {
x += y;
}
}
即可完成一次執行的優化。
好了,
下面做一個nodejs的并發服務測試一下
nestjs并發服務測試
這裡使用nestjs 建立了兩個API接口服務,使用abTest 壓測一下接口性能。
// app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
const calculate = require('../helper/calculate')
function calc(n) {
let i, x = 100.734659, y=353.2313423432;
for (i=0; i<n; i++) {
x += y;
}
return x
}
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('nodejs')
getNodejs(): string {
calc(10000);
return "nodejs ok";
}
@Get('cpp')
getCpp(): string {
calculate.calc(10000);
return "c++ ok";
}
}
先測一下nodejs 原生的性能:
ab -n 1000 -c 1000 http://127.0.0.1:3000/nodejs
# 測試結果
Document Path: /nodejs
Document Length: 2 bytes
Concurrency Level: 1000
Time taken for tests: 0.460 seconds
Complete requests: 1000
Failed requests: 0
Total transferred: 200000 bytes
HTML transferred: 2000 bytes
Requests per second: 2171.67 [#/sec] (mean)
Time per request: 460.476 [ms] (mean)
Time per request: 0.460 [ms] (mean, across all concurrent requests)
Transfer rate: 424.15 [Kbytes/sec] received
c++ addons的測試結果
ab -n 1000 -c 1000 http://127.0.0.1:3000/cpp
# 測試結果
Document Path: /cpp
Document Length: 2 bytes
Concurrency Level: 1000
Time taken for tests: 0.421 seconds
Complete requests: 1000
Failed requests: 0
Total transferred: 200000 bytes
HTML transferred: 2000 bytes
Requests per second: 2376.32 [#/sec] (mean)
Time per request: 420.818 [ms] (mean)
Time per request: 0.421 [ms] (mean, across all concurrent requests)
Transfer rate: 464.13 [Kbytes/sec] received
這裡可以看出并發資料有近9.4%的提升,請求時間都有近9.5%的下降。 這裡再次佐證了NickNaso的說法。
如果我們有資料的互動,把c++ addons裡的資料運算結果再傳回到nodejs傳回,又是什麼效果呢?代碼修改如下:
...
@Get('nodejs')
getNodejs(): string {
let num = calc(100000);
return "" + num;
}
@Get('cpp')
getCpp(): string {
let num = calculate.calc(100000);
return "" + num;
}
...
nodejs 原生寫法測試結果如下
ab -n 1000 -c 1000 http://127.0.0.1:3000/nodejs
Document Path: /nodejs
Document Length: 18 bytes
Concurrency Level: 1000
Time taken for tests: 0.450 seconds
Complete requests: 1000
Failed requests: 0
Total transferred: 218000 bytes
HTML transferred: 18000 bytes
Requests per second: 2222.43 [#/sec] (mean)
Time per request: 449.957 [ms] (mean)
Time per request: 0.450 [ms] (mean, across all concurrent requests)
Transfer rate: 473.14 [Kbytes/sec] received
c++ addons 測試結果
ab -n 1000 -c 1000 http://127.0.0.1:3000/cpp
Document Path: /cpp
Document Length: 18 bytes
Concurrency Level: 1000
Time taken for tests: 0.504 seconds
Complete requests: 1000
Failed requests: 0
Total transferred: 218000 bytes
HTML transferred: 18000 bytes
Requests per second: 1985.61 [#/sec] (mean)
Time per request: 503.623 [ms] (mean)
Time per request: 0.504 [ms] (mean, across all concurrent requests)
Transfer rate: 422.72 [Kbytes/sec] received
這麼一看涉及到資料傳遞之後,c++ addons 如果性能提升不是很明顯的話,涉及到和nodejs的值傳遞, 請求時間和QPS反而不如nodejs原生的性能更好。
那麼使用c++ addons難免會遇到值的拷貝傳遞,那麼c++ addons 優化的速度就無法避免了嗎?
抛開解決、查找、加載等子產品的時間,大部分 CPU 運作周期将用于 Node.js 和 C++ 之間的封送處理資料(涉及到跨語言資訊傳遞的都是如此)。其中更加昂貴的是字元串的傳遞。如果 Node.js 内部使用 UTF-8,那麼成本将是最小的。如果 Node.js 使用 UTF-16 或依賴于沒有 ASCII 快速路徑的 libicu,那麼對于 ASCII 資料(也包括 JSON),轉換的成本會更高一些。
在拷貝字元串資料過程中,配置設定記憶體來接收副本相對來說更加損耗性能。浮點數和整數轉換相對于字元串的傳遞性能損耗小的多,因為 Node.js 可能會使用 64 位整數和雙精度類型(因為它本身可能是用 C/C++ 編寫的),複制這些值的成本非常低,因為它們很可能都将在堆棧上傳遞。如果必須應用類型轉換或需要複制資料(是以需要配置設定記憶體),傳遞值數組一定會産生一些開銷。
既然我們知道了在反複的值拷貝的過程中都要不斷地建立和銷毀記憶體,如果我們把需要修改的記憶體内容提前聲明好,然後js 和 c++ 複用同一段記憶體來減少值拷貝的損耗。尤其是字元串的拷貝損耗是比較嚴重的,這裡就可以借助buffers進行優化處理。借用 Node.js 文檔中的一些示例,我們可以建立指定大小的初始化緩沖區、預先設定有指定值的緩沖區、位元組數組緩沖區和字元串緩沖區。
// buffer with size 10 bytes
**const** buf1 = Buffer.alloc(10);
// buffer filled with 1's (10 bytes)
**const** buf2 = Buffer.alloc(10, 1);
//buffer containing [0x1, 0x2, 0x3]
**const** buf3 = Buffer.from([1, 2, 3]);
// buffer containing ASCII bytes [0x74, 0x65, 0x73, 0x74].
**const** buf4 = Buffer.from('test');
// buffer containing bytes from a file
**const** buf5 = fs.readFileSync("some file");
緩沖區同樣可以轉換回傳統的 JavaScript ,供js使用,這樣就可以極大地提高性能。具體可參考Using Buffers to share data between Node.js and C++
以上算是測試的全部内容了,代碼位址。由此也産生了一些思考,性能和效率在軟體工程上看來并沒有銀彈,魚和熊掌不可得兼,這難道就是咱們老祖宗說的中庸之道嗎?審時度勢,量力而行,不盲從,不偏信。根據所處複雜開發環境因素、團隊技能點的長處選擇合适的開發架構和解決方案的能力,遇到問題深入探索的精神,以及深入地定位問題的本質,我想這會不會是目前短期内不能被chatgpt取代的原因之一呢,畢竟這些都是内部資料不能上傳給gpt?
文章參考
- Do C++ Addons Improve Node JS Performance? A Benchmark
- Speed up Your Node.js App with Native Addons
- 詳解 Chrome 「V8 」引擎,讓你更懂JavaScript !
- C++ addon is slower than js
- Using Buffers to share data between Node.js and C++