본문 바로가기

# Lang/NodeJS C++ Addon

[벤치마크] C++ 애드온을 도입하면 성능이 얼마나 향상될까?

시작하기에 앞서

사실 JavaScript는 꽤 빠르고 생산성도 높은 스크립팅 언어입니다. 특히 병렬성이 높은 작업에 대해서는 때때로 컴파일 언어보다 높은 성능을 보여주곤해서, 동시성이 높은 웹서버를 만들기 위한 선택지로 Node.JS + Express.js가 먼저 고려되는 경우가 많습니다.


하지만 병렬성을 요구하지 않는 연산에서는 약한 모습을 보여주는데, 수학적 연산이 집중된 암호학 분야는 특히 NodeJS가 꺼려하는 대표적인 분야 중 하나입니다.


그러나 이러한 종류의 연산은 C++ Addon을 사용하면 보완할 수 있다고 알려져있죠. 그렇다면 NodeJSC++ Addon을 도입하면 성능이 얼마나 향상될까요? 아무때나 C++ Addon을 도입하면 되는걸까요? 이 포스팅에서는 C++ Addon의 성능에 관한 다양한 벤치마크를 진행하고 결론을 도출하고자 합니다.


목차

벤치마크 주제는 다음과 같으며, 실제 벤치마크 코드는 깃허브에서 확인할 수 있습니다.

  1. 배열
    • 배열에 원소 쓰기
    • 배열의 원소 읽기
    • 배열 정렬하기
  2. 수학
    • 덧셈 연산
    • 곱셈 연산
    • 모듈러 연산
    • 비트 연산
  3. 자료구조
    • 스택
  4. 함수호출
    • 재귀호출이 적용된 피보나치
    • 동적계획이 적용된 피보나치
  5. 문자열
    • 문자열 생성하기
    • 문자열 반복하기
    • 문자열의 각 문자에 쓰기
    • 문자열의 각 문자를 변경
    • 문자열 읽기
  6. 파일 입출력
    • 파일 쓰기
    • 파일 읽기

선행지식

벤치마크를 이해하기 위한 기본적인 선행지식을 짚고 넘어가겠습니다.

  • TypedArrayArray보다 훨씬 빠르다.

Array는 무엇이든지 담을 수 있지만 TypedArray는 특별한 타입만 저장할 수 있으며 배열의 크기를 변경할 수 없습니다. 하지만 제약이 빡센만큼 쓰기는 Array와 비교될 수 없을 정도로 빠릅니다. DataView라는 버퍼를 사용해서 연산을 하기 때문이죠. TypedArray의 종류는 다음과 같습니다.

Int8Array();
Uint8Array();
Uint8ClampedArray();
Int16Array();
Uint16Array();
Int32Array();
Uint32Array();
Float32Array();
Float64Array();
BigInt64Array();
BigUint64Array();
  • 정수는 53bit만 사용하여 표현된다.

NodeJSNumber 자료형은 IEEE_754, Double-precision floating-point 포맷을 사용하여 표현됩니다. 가수부분이 53bit이므로, 53bit로 표현할 수 있는 정수보다 작으면 가수부분만을 사용하여 Integer로 취급하고 그것보다 크다면 Double로 사용됩니다.

즉, 53bit를 넘어가는 정수에 대해서는 사실상 Double이므로 손실이 발생하게됩니다. 데이터의 손실이 발생하지 않는 정수의 최대값은 Number.MAX_SAFE_INTEGER에 저장되어 있습니다. 더 자세한 내용은 모질라 도큐먼트를 참조해주세요.


  • 문자열은 변경될 수 없다.

NodeJSString 자료형은 불변Immutable속성을 가지고 있습니다. 문자열의 데이터를 멋대로 조작할 수 없다는 이야기입니다. 극단적인 예시로 1MB의 문자열에서 1글자만 변경하고 싶다 하더라도 이미 존재하는 문자열은 변경될 수 없으므로, 1글자만 변경된 1MB의 문자열이 다시 생성됩니다.


  • NAPI의 메소드는 NodeJS의 메모리와 상호작용한다.

NAPI에서 제공되는 헬퍼 메소드를 호출하면 NodeJS의 메모리와 상호작용이 이루어집니다. 이것은 서로다른 두 언어의 통신이므로, NodeJS에서 빌트인으로 제공되는 연산을 사용하는 것 보다 훨씬 값비싼 비용을 치뤄야합니다. 즉, NAPI의 함수를 최대한 적게 사용해야 성능적으로 좋습니다.


벤치마크

배열

원소 쓰기

for(let i=0; i<N; i++){
    arr[i] = i;
}

시나리오는 다음 중 하나입니다.

  • Array + ADDON
  • Array + NODE
  • Int32Array + ADDON
  • Int32Array + NODE

단순히 TypedArray를 사용함으로써 성능을 향상시킬 수 있었습니다. 특히 NAPI에서 TypedArray가 사용된 경우의 성능향상이 매우 극적인데, 이것은 NAPITypedArray의 내부버퍼를 NodeJS의 개입없이 다이렉트로 액세스할 수 있기 때문입니다.


원소 읽기

for(let i=0; i<N; i++){
   arr[i]; 
}

시나리오는 다음 중 하나입니다.

  • Array + ADDON
  • Array + NODE
  • Int32Array + ADDON
  • Int32Array + NODE

Array + Addon 조합이 매우 압도적인 오버헤드를 자랑합니다. Array는 무엇이든 담을 수 있기 때문에 V8에서 한번 보호장치를 거쳐가기 때문입니다. 반면 TypedArray는 다른 개입없이 내부버퍼에 직접 액세스할 수 있기 때문에 빠릅니다.


원소 정렬

Node에서는 Array.prototype.sort()를 사용하고,
NAPI에서는 std::sort()를 사용합니다.

시나리오는 다음 중 하나입니다.

  • Addon + Array (단, 정렬된 배열을 반환하지 않음)
  • Addon + TypedArray
  • Node + Array
  • Node + TypedArray

배열의 상황은 다음 중 하나입니다.

  • 정렬된 경우.
  • 정렬되지 않은 경우.
  • 전부 같은값인 경우.

여전히 TypedArray가 강세를 보이고있습니다. Addon + Array은 정렬된 배열을 반환하지 않는다고 가정했으므로, 값을 반환했어야 한다면 Node + Array와 비슷하거나 더 느린 퍼포먼스를 보였을 것입니다.


수학연산

덧셈

let sum = 0;
for(let i=0; i<N; i++){
   sum += i; 
}

일반적인 통념과 맞아 떨어집니다. 단순 연산은 C++이 더 빠르네요.


곱셈

let typedArray = new Int32Array(N)
for(let i=0; i<N; i++) {
    typedArray[i] = i * 1.5; 
}

일반적인 통념과 맞아 떨어집니다. 단순 연산은 C++이 더 빠르네요.


모듈러

let arr = new Int32Array(N);
let out = new Int32Array(N);
for(let i=0; i<N; i++){
    out[i] = arr[i] % 7; 
}

일반적인 통념과 맞아 떨어집니다. 단순 연산은 C++이 더 빠르네요.


비트 연산

const arr = new Int32Array(N);
const ans = new Int32Array(N);
for (let i = 0; i < N; i++) {
    let v = i;
    v |= i;
    v &= i;
    v <<= 1;
    v = ~v;
    v >>= 1;
    ans[i] = v;
}

일반적인 통념과 맞아 떨어집니다. 단순 연산은 C++이 더 빠르네요.


자료구조

스택

for(let i=0; i<N; i++){
   stack.push(i);
   stack.top();
}

스택은 다음의 조합으로 만들었습니다.

  • Addon + 포인터배열 + Napi::ObjectWrap
  • Node + Array
  • Node + TypedArray + Class

C++로 구현한 스택의 경우에는 push()또는 pop()이 호출될 때 마다, 오버헤드가 발생되어 심각한 병목지점으로 작용되었습니다. Node + TypedArray + Class의 경우에는 굳이 클래스로 만들지 않는다면 성능이 더 좋아집니다만, 객체지향에는 맞지 않습니다. Trade-Off를 생각하면서 적용하는것이 좋습니다.


let map;  // key: int, value: int

for(let i=0; i<N; i++){
   map.set(i, i);
   map.get(i);
}

맵은 다음 조합으로 만들었습니다.

  • Addon + 포인터배열 + Napi::ObjectWrap
  • Node + Map
  • Node + Object
  • Node + TypedArray + Class

C++로 구현한 맵의 경우에는 set()또는 get()이 호출될 때 마다, 오버헤드가 발생되어 심각한 병목지점으로 작용되었습니다. Node + TypedArray + Class의 경우에는 굳이 클래스로 만들지 않는다면 성능이 더 좋아집니다만, 객체지향에는 맞지 않습니다. Trade-Off를 생각하면서 적용하는것이 좋습니다.


함수호출

재귀호출

function fibo(n: number): number {
    if (n === 1) return 0;
    if (n === 2) return 1;
    if (n === 3) return 1;
    return fibo(n - 1) + fibo(n - 2);
}

이것으로 함수호출 스택을 쌓는 비용을 알 수 있었습니다. C++의 함수호출이 훨씬 더 가볍네요.


메모이제이션

이번에는 캐시를 두어 피보나치를 수행해볼까요. 각 테스트케이스는 캐시를 비우고 시작했습니다.

동적계획을 적용한것 뿐인데 퍼포먼스가 엄창나게 향상되었습니다. 프로그램의 성능은 먼저 알고리즘에 달려있다는 것을 알 수 있습니다.


문자열

모든 문자를 순회

C++

size_t charSum = 0;
for(const char &ch : str){
    charSum += ch;
}

NodeJS

const len = str.length;
let charSum = 0;
for (let i = 0; i < len; i++) {
    charSum += str.charCodeAt(i);
}

문자열이 커질수록 C++의 성능이 더 좋아지지만, 미세합니다. 어느 것을 사용해도 큰 차이가 없습니다.


각 문자를 변경

이진수 형태의 문자열이 주어지면, 각 숫자가 반전된 문자열을 출력해보겠습니다.

C++

for(char &ch : str){
    ch = (ch == '0' ? '1' : '0');
}

NodeJS - Using Array

const arr = [];
const len = str.length;
for (let i = 0; i < len; i++) {
    const ch = str.charAt(i);
    arr.push(ch === "0" ? "1" : "0");
}
str = arr.join();

NodeJS - Using Buffer

const buf = Buffer.from(str);
const ch0 = "0".charCodeAt(0);
const ch1 = "1".charCodeAt(0);
for(let i=0; i<buf.length; i++){
    buf[i] = (buf[i] === ch0) ? ch1 : ch0;
}
buf.toString();

NodeJS에서 문자열은 불변이기 때문에 C++처럼 인덱스 기반으로 문자열을 수정할 수 없습니다. 문자열을 한 글자씩 잘라내어 작업하거나, Buffer로 변환하여 작업하는 방법이 있을 수 있습니다. 또한 Buffer를 사용한 방법이 C++에 매우 근접한 성능을 보여주고 있음을 알 수 있습니다.


문자열 배열 생성하기

C++

auto arr = Napi::Array::New(env, N);
for(auto i=0; i<N; i++){
    arr[i] = Napi::String::New(env, std::to_string(i));
}

NodeJS

const arr : string[] = [];
for(let i=0; i<N; i++){
   arr[i] = String(i); 
}

C++에서 문자열을 생성하는 것이 매우 비싼 작업임을 알 수 있습니다. 다음 함수들이 병목지점으로 작용하고 있네요.

  • 숫자를 C++문자열로 변경하는 함수의 비용
  • C++문자열을 NodeJS문자열로 변환하는 함수의 비용
  • NodeJS문자열을 NodeJS배열에 삽입하는 함수의 비용

숫자를 문자열로 변환하는 함수를 튜닝한다 치더라도 나머지 2개는 치명적입니다. 대량의 문자열을 만드는 작업은 NodeJS에서 진행하는것이 더 낫겠네요.


그러나 1개의 커다란 문자열을 생성하는 경우에는 빠져나갈 방법이 있습니다. Uint8Array의 형태로 문자열을 전달하는 것이죠. 그렇게하면, 먼저 std::stringnapi::string으로 변환하는 작업을 피할 수 있고, NodeJS의 메모리에 직접적으로 읽기/쓰기가 가능하므로 assign의 성능도 향상됩니다.


문자열 반복하기

주어진 문자열을 N배 만큼 반복한 문자열을 만들겠습니다. 각각 시간 복잡도가 O(n), O(log n)인 방법을 사용하였습니다.

O(n)

let repeated = "";
for (let i = 0; i < N; i++) {
    repeated += str;
}

O(log n)

let repeated = "";
while (N !== 0) {
    if (N & 1) {
        repeated += str;
    }
    str += str;
    N >>= 1;
}

0.5us미만의 값은 0us으로 표기되었습니다.

같은 log N알고리즘을 사용하였음에도 AddonNodeJS의 성능차이가 매우 심한것에 주목해주시기 바랍니다. 특히 C++에서는 반복 문자열을 생성하는 시간보다 다른 작업들이 더 오래 걸림을 알 수 있습니다. 아마 큰 문자열을 Napi::string으로 변환하는데 비싼 비용을 치뤘다고 생각됩니다.


그렇다쳐도 반복 문자열을 생성하는 작업NodeJS에서 더 빨리 끝나는 것이 의문입니다. Node의 문자열C++의 문자열과 다르게 동작하는 것 같습니다. 이에 대해서는 추후 조사하고 포스팅하겠습니다.


NAPI에 문자열 전달하기

NAPI에 문자열을 넘기기 위해 다음과 같은 방법을 생각할 수 있습니다.

  • napi::string으로 넘겨서 std::string으로 읽는다.
  • Uint8Array로 넘겨서 char*으로 읽고 std::string으로 변환한다.
  • Uint8Array로 넘겨서 char*으로 읽는다.

벤치마크만 봐서는 항상 Uint8Array으로 넘기는 것이 유리한 것 같지만, NodeJS에서 StringBuffer로 변환하는 작업의 비용도 같이 생각해야합니다. 따로 측정한 벤치마크의 결과를 아래에 적습니다.

  • N === 1e4 ) 80us
  • N === 1e5 ) 1,000us
  • N === 1e6 ) 12,220us

위의 벤치마크도 같이 생각하면, 2번 이상 NAPI에 전달해야 할 때만 Uint8Array가 효과적임을 알 수 있습니다.


파일 입출력

파일 쓰기

Addonfopen, fwrite, fclose를 사용했고,
NodeJSfileWriteSync를 사용했습니다.


파일 읽기

Addonfopen, fread, fclose를 사용했고,
NodeJSfileReadSync를 사용했습니다.