본문 바로가기

Javascript

[Wasm] 웹어셈블리(c++) 오버헤드 테스트

 

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가지로 나뉜다.

 

  1. 메모리 자원을 많이 소비한다
  2. 시간 자원을 많이 소비한다

여기서 메모리 자원은 배제하고 시간자원에 포커싱을 맞추자면, 해당 자원에 로직상 직접적인 영향을 주는 요인은 Loop이다.

 

추가적으로 Function Call을 선정한 이유는, 2가지 이유가 있는데 다음과 같다.

 

  1. 자바스크립트와 JIT 컴파일 되는 과정이 다르다. - WASM의 바이트 코드의 경우 wasm runtime에 의해 중간(intermediate)언어로 트랜스파일링 되는데 이 과정을 거치고 기계언어로 변환된다.
  2. 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 환경에서 사용하는 옵션이며 상세한 내용은 아래 링크를 참조해주시면 좋을듯 합니다.

 

https://emscripten.org/docs/compiling/Building-Projects.html?highlight=optimization#building-projects-with-optimizations

 

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;
}

 

 

 

결과

noopt

 

opt

 

 

솔직히 단순 반복문의 경우에는 둘다 똑같을거라 생각했는데, 거의 차이 없긴하지만 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 사용은 비효율적이다.