Intro.
c++가 특성상 자바스크립트보다 빠르나 웹 어셈블리의 경우, 컴파일된 bytecode를 가지고 wasm runTime에 중간 언어로 변환된 후 JIT 컴파일 되는 과정을 거친다.
물론 자바스크립트 또한 JIT 컴파일되고 그 과정에서 자잘한 최적화가 이루어진다.
그럼 어느 상황즈음 되어야 자바스크립트 코드에서 WASM으로 전환했을 때, 성능적 이득을 볼 수 있을까?
리서치를 해도 보통 그냥 "네이티브에 유사한 속도를 가집니다" 정도로만 끝내기에 이번 기회에 한번 테스트해보기로 했다.
따라서, 3가지 비교군에 대하여 Loop + Function call 이 2가지 항목에 대해 비교 분석을 해보았다.
비교군은 다음과 같다.
- Vanila JS
- WASM - c++ 17 (noopt)
- WASM - c++ 17 (opt)
먼저 항목은 왜 저 2가지만 하냐고 궁금해 할 수 있을것 같아 이유에 대해 이야기하려한다.
먼저 WASM을 사용하는 시나리오는 본인이 생각하기에, 일반적으로 퍼포먼스 향상 측면이 크다고 생각한다.
즉, 로직이 상당하게 무거울 확률이 높을것이라 판단하였고 로직이 무겁다는 뜻은 크게 2가지로 나뉜다.
- 메모리 자원을 많이 소비한다
- 시간 자원을 많이 소비한다
여기서 메모리 자원은 배제하고 시간자원에 포커싱을 맞추자면, 해당 자원에 로직상 직접적인 영향을 주는 요인은 Loop이다.
추가적으로 Function Call을 선정한 이유는, 2가지 이유가 있는데 다음과 같다.
- 자바스크립트와 JIT 컴파일 되는 과정이 다르다. - WASM의 바이트 코드의 경우 wasm runtime에 의해 중간(intermediate)언어로 트랜스파일링 되는데 이 과정을 거치고 기계언어로 변환된다.
- WASM 호스트는 기본적으로 security boundary의 역할을 하고 있으며 하위 게스트 모듈은 모든 자원에 대해 권한이 deny 상태고, 이 부분에 대한 검증 과정이 있으며 ,이후 기계 언어로 변환 시 validation phase가 껴있다. (참고로 권한 검증과정이랑 validation phase랑은 다른 과정이다)
따라서, 위 두 가지 오버헤드를 상정할 때, 해당 요소들이 실행시간에 얼마나 영향을 끼치는지 또한 알아보고싶었다.
테스트 환경은 아래와 같다.
Macbook pro 16 (2023) (MRW23KH/A)
Apple M3 Pro
emcc (3.1.61)
c++ (v17)
browser : chrome (125.0.6422.76) (arm64)
테스트 세팅
테스트 실행 코드는 아래와 같다.
round(실행 횟수)을 가지고 실행횟수의 평균시간을 구한다.
const Test = (config) => {
const DEFAULT_ROUND = config?.round || 10;
const wasmMode = config?.wasmMode || 0;
const name = `WASM(${wasmMode === 1 ? "opt" : "noopt"}) & Vanila Performance comparison`;
function run(func, verbose = false) {
const start = performance.now();
func();
const end = performance.now();
if (verbose) {
console.log(`Execution took ${end - start}ms`);
}
return end - start;
}
return {
measure: (Suite, count = DEFAULT_ROUND, verbose = false) => {
const wasmRes = [];
const vanilaRes = [];
console.log(`Starting ${name}....`);
console.log(`Suite name : ${Suite.name}`);
if (verbose) {
console.log("Running on env : WASM");
}
for (let i = 0; i < count; i++) {
wasmRes.push(run(Suite.wasm.bind(Suite), verbose));
}
if (verbose) {
console.log("Running on env : Vanila");
}
for (let i = 0; i < count; i++) {
vanilaRes.push(run(Suite.vanila.bind(Suite), verbose));
}
const vanilaAv =
vanilaRes.reduce((acc, val) => {
return acc + val;
}, 0) / count;
const wasmAv =
wasmRes.reduce((acc, val) => {
return acc + val;
}, 0) / count;
console.log(`For ${count} rounds..`);
if (vanilaAv < wasmAv) {
console.log(`%c Vanila took ${vanilaAv}ms average`, "color: #00ff00; background-color: #000000");
console.log(`%c WASM took ${wasmAv}ms average`, "color: #ffff00; background-color: #000000");
} else if (vanilaAv > wasmAv) {
console.log(`%c Vanila took ${vanilaAv}ms average`, "color: #ffff00; background-color: #000000;");
console.log(`%c WASM took ${wasmAv}ms average`, "color: #00ff00; background-color: #000000;");
} else {
console.log(`%c Vanila took ${vanilaAv}ms average`, "color: #00ff00; background-color: #000000;");
console.log(`%c WASM took ${wasmAv}ms average`, "color: #00ff00; background-color: #000000;");
}
},
};
};
c++ 컴파일을 위한 명령어는 아래와 같다.
// noopt
emcc ./test.cpp -o ./test_wasm.js --std=c++17 --bind
// opt
emcc -O3 ./test.cpp -o ./test_wasm.js --std=c++17 --bind
최적화 옵션인 -O3 옵션은 일반적인 release 환경에서 사용하는 옵션이며 상세한 내용은 아래 링크를 참조해주시면 좋을듯 합니다.
Building Projects — Emscripten 3.1.61-git (dev) documentation
Emscripten Ports is a collection of useful libraries, ported to Emscripten. They reside on GitHub, and have integration support in emcc. When you request that a port be used, emcc will fetch it from the remote server, set it up and build it locally, then l
emscripten.org
Loop
먼저 이걸 왜 하나 싶겠지만, 엄밀하게 for 문 자체에 대한 구현 차이 가능성이 존재하므로 테스트해보았다.
테스트 코드
class LoopTest {
constructor(iteration = 10) {
this.name = `Loop Test (${iteration} iteration)`;
// custom
this.iteration = iteration;
}
vanila() {
let a = 0;
for (let i = 0; i < this.iteration; i++) {
a++;
}
return;
}
wasm() {
Module.loopTest(this.iteration);
return;
}
}
int loopTest(int cnt){
int a = 0;
for(int i = 0 ; i < cnt ; i++){
a ++;
}
return a;
}
결과
솔직히 단순 반복문의 경우에는 둘다 똑같을거라 생각했는데, 거의 차이 없긴하지만 optWasm이 좀 더 빠르다.
처음엔, 정말 혹시나 자바스크립트가 변수 타입 추론하는것 때문에 오래걸렸나? 하고 변수 선언 과정도 빼봤지만 역시나 차이는 없었다.
-O3 최적화가 컴파일 시, Linking 과정과 연관된 최적화라던데 한번 찾아봐야겠다.
이 글을 본 지인이 AST 트리 만들고 파싱하는 과정에서 시간 더 걸리지 않았나? 하고 묻기에 혹시나 다른 분들도 비슷한 의문이 들 수 있을것 같아서 적자면,
만약 트랜스파일링 과정에서 문제라면, 애당초 시간 차이가 반복횟수에 의해 선형적으로 늘어날 수 가 없다. 그리고 저렇게 오래 안걸린다.
만약 테스트 해보고 싶다면 위에 적은 코드에 반복횟수를 0으로 실행 시켜보면 vanila 환경이 0ms걸리는걸 알 수 있다.(즉, 트랜스파일링이 측정불가일 정도로 굉장히 빠르다는 뜻)
정리하자면, 단순 Loop 성능을 비교하면 성능적 차이는 거의 없다.
즉, 다음 function call을 수행할 때, for 문 수행이 그리 큰 영향을 미치지 않을 것이라 판단 가능하다.
Function Call
각 함수의 반복횟수 (iteration count)를 변화시켜가면서 호출 횟수 (call count)를 별 실행시간을 측정한다.
또한 반복횟수가 N인 함수를 Task - N 이라 지칭하겠다.
Task - 0
Task - 10
Task - 10000
Task - 100000
Task - 100000 의 경우, vanila와 optWasm과 시간차이가 너무 많이나서 처음에 테스트 설계를 잘못한줄 알았다.
일단 실행시간이 너무 기하급수적으로 커져서 호출 횟수를 1만번을 최대로 하여 그래프를 다시 그렸을때는 다음과 같다.
일단 내린 결론은 , 연산 횟수가 최소 1M은 되어야 성능 향상을 느낄 수 있다 생각이든다. 그 이하는 시간낭비 인 듯하다.
번외 : Map vs unordered-map
개인적인 호기심으로 자바스크립트의 Map과 c++의 해시맵(unordererd-map)과의 비교를 해보았다.
MDN Map 스펙에 따르면 Map은 입력한 순서를 기억한다고 해서, ordered Map과 대응하는줄 알았는데, 아래 글에서 unordered_map과 대응한다는 글이 있길래 한번 테스트 해보았다.
https://stackoverflow.com/questions/68643084/is-there-equivalent-to-c-unordered-map-in-javascript
Is there equivalent to C++ `unordered_map` in javascript
From MDN, javascript provides Map which is C++ equivalent to std::map. Is there a similar equivalent to unordered_map (providing O(1) insertions/lookup/deletion on avg). Edit: As the answers sugges...
stackoverflow.com
참고로 c++ ordered_map은 매 입력마다 정렬을 다시한다.
일단 결과로는 차이가 없다. 세부 구현 스펙은 다르더라도 그 자체로 O(1)의 접근 속도니 뭐...
스샷은 안찍었으나, 메모리 할당 속도도 거의 차이가 없다.
class MapReadTest {
constructor(wideness = 10) {
this.name = `Object Getter Width Test :: wideness - ${wideness} `;
// custom
this.wideness = wideness;
this.map = new Map();
this.case = [];
}
init(round) {
for (let i = 0; i < this.wideness; i++) {
this.map.set(i, i);
}
Module.MapReadTestInit(this.wideness);
for (let i = 0; i < round; i++) {
this.case.push(Math.floor(Math.random() * this.wideness));
}
}
vanila(search) {
this.map.get(this.case[search]);
}
wasm(search) {
Module.MapReadTest(this.case[search]);
}
}
unordered_map<int, int> mapInstance;
void MapReadTestInit(unsigned int width){
for(int i = 0; i < width ; i ++){
mapInstance[i] = i;
}
}
int MapReadTest(const unsigned int search){
return mapInstance[search];
}
결론 :: 정말 고비용 혹은 고 반복하는 연산이 없다면, 퍼포먼스 향상을 위한 WASM 사용은 비효율적이다.
'Javascript' 카테고리의 다른 글
[javascript] 불변성(Immutability)을 위한 자료구조적 최적화 (1) | 2023.09.03 |
---|---|
[Javascript] JS Engine (자바스크립트 엔진에 대하여) (0) | 2023.05.07 |
[Javascript + Web] Event loop & Async (이벤트 루프 & 비동기) (0) | 2023.03.29 |
[Javascript] Generator (제너레이터) (0) | 2023.03.12 |
[Javascript] Symbol (0) | 2023.03.09 |