시작하기에 앞서
사실 JavaScript
는 꽤 빠르고 생산성도 높은 스크립팅 언어입니다. 특히 병렬성
이 높은 작업에 대해서는 때때로 컴파일 언어보다 높은 성능을 보여주곤해서, 동시성이 높은 웹서버를 만들기 위한 선택지로 Node.JS + Express.js
가 먼저 고려되는 경우가 많습니다.
하지만 병렬성
을 요구하지 않는 연산에서는 약한 모습을 보여주는데, 수학적 연산이 집중된 암호학 분야
는 특히 NodeJS
가 꺼려하는 대표적인 분야 중 하나입니다.
그러나 이러한 종류의 연산은 C++ Addon
을 사용하면 보완할 수 있다고 알려져있죠. 그렇다면 NodeJS
에 C++ Addon
을 도입하면 성능이 얼마나 향상될까요? 아무때나 C++ Addon
을 도입하면 되는걸까요? 이 포스팅에서는 C++ Addon
의 성능에 관한 다양한 벤치마크를 진행하고 결론을 도출하고자 합니다.
목차
벤치마크 주제는 다음과 같으며, 실제 벤치마크 코드는 깃허브에서 확인할 수 있습니다.
- 배열
- 배열에 원소 쓰기
- 배열의 원소 읽기
- 배열 정렬하기
- 수학
- 덧셈 연산
- 곱셈 연산
- 모듈러 연산
- 비트 연산
- 자료구조
- 스택
- 맵
- 함수호출
- 재귀호출이 적용된 피보나치
- 동적계획이 적용된 피보나치
- 문자열
- 문자열 생성하기
- 문자열 반복하기
- 문자열의 각 문자에 쓰기
- 문자열의 각 문자를 변경
- 문자열 읽기
- 파일 입출력
- 파일 쓰기
- 파일 읽기
선행지식
벤치마크를 이해하기 위한 기본적인 선행지식을 짚고 넘어가겠습니다.
TypedArray
는Array
보다 훨씬 빠르다.
Array
는 무엇이든지 담을 수 있지만 TypedArray
는 특별한 타입만 저장할 수 있으며 배열의 크기를 변경할 수 없습니다. 하지만 제약이 빡센만큼 쓰기는 Array
와 비교될 수 없을 정도로 빠릅니다. DataView
라는 버퍼를 사용해서 연산을 하기 때문이죠. TypedArray
의 종류는 다음과 같습니다.
Int8Array();
Uint8Array();
Uint8ClampedArray();
Int16Array();
Uint16Array();
Int32Array();
Uint32Array();
Float32Array();
Float64Array();
BigInt64Array();
BigUint64Array();
- 정수는
53bit
만 사용하여 표현된다.
NodeJS
의 Number
자료형은 IEEE_754, Double-precision floating-point
포맷을 사용하여 표현됩니다. 가수부분이 53bit
이므로, 53bit
로 표현할 수 있는 정수보다 작으면 가수부분만을 사용하여 Integer
로 취급하고 그것보다 크다면 Double
로 사용됩니다.
즉, 53bit
를 넘어가는 정수에 대해서는 사실상 Double
이므로 손실이 발생하게됩니다. 데이터의 손실이 발생하지 않는 정수의 최대값은 Number.MAX_SAFE_INTEGER
에 저장되어 있습니다. 더 자세한 내용은 모질라 도큐먼트를 참조해주세요.
- 문자열은 변경될 수 없다.
NodeJS
의 String
자료형은 불변
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
가 사용된 경우의 성능향상이 매우 극적인데, 이것은 NAPI
가 TypedArray
의 내부버퍼를 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::string
을 napi::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
알고리즘을 사용하였음에도 Addon
과 NodeJS
의 성능차이가 매우 심한것에 주목해주시기 바랍니다. 특히 C++
에서는 반복 문자열을 생성하는 시간
보다 다른 작업들
이 더 오래 걸림을 알 수 있습니다. 아마 큰 문자열을 Napi::string
으로 변환하는데 비싼 비용을 치뤘다고 생각됩니다.
그렇다쳐도 반복 문자열을 생성하는 작업
이 NodeJS
에서 더 빨리 끝나는 것이 의문입니다. Node의 문자열
은 C++의 문자열
과 다르게 동작하는 것 같습니다. 이에 대해서는 추후 조사하고 포스팅하겠습니다.
NAPI에 문자열 전달하기
NAPI
에 문자열을 넘기기 위해 다음과 같은 방법을 생각할 수 있습니다.
napi::string
으로 넘겨서std::string
으로 읽는다.Uint8Array
로 넘겨서char*
으로 읽고std::string
으로 변환한다.Uint8Array
로 넘겨서char*
으로 읽는다.
벤치마크만 봐서는 항상 Uint8Array
으로 넘기는 것이 유리한 것 같지만, NodeJS
에서 String
을 Buffer
로 변환하는 작업의 비용도 같이 생각해야합니다. 따로 측정한 벤치마크의 결과를 아래에 적습니다.
- N === 1e4 ) 80us
- N === 1e5 ) 1,000us
- N === 1e6 ) 12,220us
위의 벤치마크도 같이 생각하면, 2번 이상 NAPI
에 전달해야 할 때만 Uint8Array
가 효과적임을 알 수 있습니다.
파일 입출력
파일 쓰기
Addon
은 fopen
, fwrite
, fclose
를 사용했고,NodeJS
는 fileWriteSync
를 사용했습니다.
파일 읽기
Addon
은 fopen
, fread
, fclose
를 사용했고,NodeJS
는 fileReadSync
를 사용했습니다.
'# Lang > NodeJS C++ Addon' 카테고리의 다른 글
[튜토리얼] C++ 크로스플랫폼 애드온 제작하기 (0) | 2020.03.14 |
---|---|
[DeepDive] NodeJS C++ Addon 깊게 입문하기 (4) | 2020.03.11 |