웹 앱의 WebAssembly 성능 패턴

WebAssembly를 활용하려는 웹 개발자를 대상으로 하는 이 가이드에서는 실행 중인 예시를 통해 Wasm을 활용하여 CPU 집약적인 작업을 아웃소싱하는 방법을 알아봅니다. 이 가이드에서는 Wasm 모듈 로드 권장사항부터 컴파일 및 인스턴스화 최적화에 이르기까지 모든 내용을 다룹니다. 또한 CPU 집약적인 작업을 웹 작업자로 전환하는 방법을 설명하고 언제 웹 작업자를 생성할지, 웹 작업자를 영구적으로 유지할지 아니면 필요할 때 가동할지 등 어떤 구현 결정을 내리게 될지 살펴봅니다. 이 가이드는 문제에 대한 최상의 솔루션을 제안할 때까지 반복적으로 접근 방식을 개발하고 한 번에 하나의 성능 패턴을 소개합니다.

가정

네이티브에 가까운 성능을 제공하기 위해 WebAssembly (Wasm)에 아웃소싱하고자 하는 CPU 집약적인 작업이 있다고 가정해 보겠습니다. 이 가이드에서 예로 사용된 CPU 집약적인 작업은 숫자의 계승을 계산합니다. 계승은 정수와 그 아래 모든 정수의 곱입니다. 예를 들어 4의 계승 (4!로 작성됨)은 24 (4 * 3 * 2 * 1)와 같습니다. 숫자가 빠르게 커집니다. 예를 들어 16!2,004,189,184입니다. CPU를 많이 사용하는 작업의 보다 현실적인 예로 바코드 스캔이나 래스터 이미지 추적을 들 수 있습니다.

factorial() 함수의 성능 기준에 맞는 반복 구현은 C++로 작성된 다음 코드 샘플에 나와 있습니다.

#include <stdint.h>

extern "C" {

// Calculates the factorial of a non-negative integer n.
uint64_t factorial(unsigned int n) {
    uint64_t result = 1;
    for (unsigned int i = 2; i <= n; ++i) {
        result *= i;
    }
    return result;
}

}

이 문서의 나머지 부분에서는 모든 코드 최적화 권장사항을 사용하여 factorial.wasm라는 파일에 Emscripten으로 이 factorial() 함수를 컴파일하는 기반이 되는 Wasm 모듈이 있다고 가정해 보겠습니다. 방법을 다시 알아보려면 ccall/cwrap을 사용하여 JavaScript에서 컴파일된 C 함수 호출을 참고하세요. 다음 명령어는 factorial.wasm독립형 Wasm으로 컴파일하는 데 사용되었습니다.

emcc -O3 factorial.cpp -o factorial.wasm -s WASM_BIGINT -s EXPORTED_FUNCTIONS='["_factorial"]'  --no-entry

HTML에는 output 및 제출 button와 페어링된 input가 있는 form가 있습니다. 이러한 요소는 이름을 기준으로 자바스크립트에서 참조됩니다.

<form>
  <label>The factorial of <input type="text" value="12" /></label> is
  <output>479001600</output>.
  <button type="submit">Calculate</button>
</form>
const input = document.querySelector('input');
const output = document.querySelector('output');
const button = document.querySelector('button');

모듈 로드, 컴파일, 인스턴스화

Wasm 모듈을 사용하려면 먼저 로드해야 합니다. 웹에서는 fetch() API를 통해 이 작업을 수행합니다. 웹 앱이 CPU를 많이 사용하는 작업을 위해 Wasm 모듈을 사용하므로 최대한 빨리 Wasm 파일을 미리 로드해야 합니다. 앱의 <head> 섹션에서 CORS 지원 가져오기를 사용하면 됩니다.

<link rel="preload" as="fetch" href="factorial.wasm" crossorigin />

실제로 fetch() API는 비동기식이므로 결과를 await해야 합니다.

fetch('factorial.wasm');

다음으로 Wasm 모듈을 컴파일하고 인스턴스화합니다. 이러한 작업을 위해 WebAssembly.compile()(및 WebAssembly.compileStreaming()) 및 WebAssembly.instantiate()라는 유혹적인 함수가 있지만, 대신 WebAssembly.instantiateStreaming() 메서드는 스트리밍 기본 소스에서 직접 Wasm 모듈을 컴파일하고 await은 필요하지 않습니다. await는 필요하지 않습니다.fetch() 이는 Wasm 코드를 로드하는 가장 효율적이고 최적화된 방법입니다. Wasm 모듈이 factorial() 함수를 내보내면 바로 사용할 수 있습니다.

const importObject = {};
const resultObject = await WebAssembly.instantiateStreaming(
  fetch('factorial.wasm'),
  importObject,
);
const factorial = resultObject.instance.exports.factorial;

button.addEventListener('click', (e) => {
  e.preventDefault();
  output.textContent = factorial(parseInt(input.value, 10));
});

웹 작업자로 작업 전환

CPU 집약적인 작업으로 기본 스레드에서 이 작업을 실행하면 전체 앱이 차단될 위험이 있습니다. 일반적인 방법은 이러한 작업을 웹 작업자로 전환하는 것입니다.

기본 스레드 재구성

CPU를 많이 사용하는 작업을 웹 작업자로 옮기기 위한 첫 번째 단계는 애플리케이션을 재구성하는 것입니다. 이제 기본 스레드는 Worker를 만들며, 그 외에도 웹 작업자에 입력을 전송한 다음 출력을 수신하여 표시하는 작업만 처리합니다.

/* Main thread. */

let worker = null;

// When the button is clicked, submit the input value
//  to the Web Worker.
button.addEventListener('click', (e) => {
  e.preventDefault();

  // Create the Web Worker lazily on-demand.
  if (!worker) {
    worker = new Worker('worker.js');

    // Listen for incoming messages and display the result.
    worker.addEventListener('message', (e) => {
      output.textContent = e.result;
    });
  }

  worker.postMessage({ integer: parseInt(input.value, 10) });
});

나쁨: 작업이 Web Worker에서 실행되지만 코드가 선정적임

웹 작업자는 Wasm 모듈을 인스턴스화하고 메시지를 수신하면 CPU를 많이 사용하는 작업을 실행하고 결과를 다시 기본 스레드로 전송합니다. 이 접근 방식의 문제는 WebAssembly.instantiateStreaming()를 사용하여 Wasm 모듈을 인스턴스화하는 것이 비동기 작업이라는 것입니다. 즉, 선정적인 코드가 됩니다. 최악의 경우 웹 작업자가 아직 준비되지 않았을 때 기본 스레드에서 데이터를 전송하고 웹 작업자는 메시지를 수신하지 못합니다.

/* Worker thread. */

// Instantiate the Wasm module.
// 🚫 This code is racy! If a message comes in while
// the promise is still being awaited, it's lost.
const importObject = {};
const resultObject = await WebAssembly.instantiateStreaming(
  fetch('factorial.wasm'),
  importObject,
);
const factorial = resultObject.instance.exports.factorial;

// Listen for incoming messages, run the task,
// and post the result.
self.addEventListener('message', (e) => {
  const { integer } = e.data;
  self.postMessage({ result: factorial(integer) });
});

우수: 태스크가 Web Worker에서 실행되지만 로드 및 컴파일이 중복될 수 있음

비동기 Wasm 모듈 인스턴스화 문제를 해결하는 한 가지 방법은 Wasm 모듈 로드, 컴파일 및 인스턴스화를 모두 이벤트 리스너로 옮기는 것이지만, 이 작업은 수신된 모든 메시지에서 실행되어야 합니다. HTTP 캐싱과 HTTP 캐시가 컴파일된 Wasm 바이트 코드를 캐시할 수 있으므로 최악의 해결책은 아니지만 더 나은 방법이 있습니다.

비동기 코드를 웹 작업자의 시작 부분으로 이동하고 실제로 프로미스가 실행될 때까지 기다리지 않고 프로미스를 변수에 저장하면 프로그램이 즉시 코드의 이벤트 리스너 부분으로 이동하고 기본 스레드의 메시지가 손실되지 않습니다. 그러면 이벤트 리스너 내에서 프로미스를 기다릴 수 있습니다.

/* Worker thread. */

const importObject = {};
// Instantiate the Wasm module.
// 🚫 If the `Worker` is spun up frequently, the loading
// compiling, and instantiating work will happen every time.
const wasmPromise = WebAssembly.instantiateStreaming(
  fetch('factorial.wasm'),
  importObject,
);

// Listen for incoming messages
self.addEventListener('message', async (e) => {
  const { integer } = e.data;
  const resultObject = await wasmPromise;
  const factorial = resultObject.instance.exports.factorial;
  const result = factorial(integer);
  self.postMessage({ result });
});

좋음: 작업이 Web Worker에서 실행되고 한 번만 로드 및 컴파일됨

정적 WebAssembly.compileStreaming() 메서드의 결과는 WebAssembly.Module로 확인되는 프로미스입니다. 이 객체의 한 가지 유용한 기능은 postMessage()를 사용하여 전송할 수 있다는 것입니다. 즉, Wasm 모듈은 기본 스레드에서 한 번만 로드하고 컴파일한 다음 (또는 로드와 컴파일에만 관심이 있는 다른 웹 작업자) CPU 집약적인 작업을 담당하는 웹 작업자에게 전송할 수 있습니다. 다음 코드는 이 흐름을 보여줍니다.

/* Main thread. */

const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));

let worker = null;

// When the button is clicked, submit the input value
// and the Wasm module to the Web Worker.
button.addEventListener('click', async (e) => {
  e.preventDefault();

  // Create the Web Worker lazily on-demand.
  if (!worker) {
    worker = new Worker('worker.js');

    // Listen for incoming messages and display the result.
    worker.addEventListener('message', (e) => {
      output.textContent = e.result;
    });
  }

  worker.postMessage({
    integer: parseInt(input.value, 10),
    module: await modulePromise,
  });
});

웹 작업자 측에서는 WebAssembly.Module 객체를 추출하여 인스턴스화하기만 하면 됩니다. WebAssembly.Module가 있는 메시지는 스트리밍되지 않으므로 이제 Web Worker의 코드는 이전의 instantiateStreaming() 변형이 아닌 WebAssembly.instantiate()를 사용합니다. 인스턴스화된 모듈은 변수에 캐시되므로 인스턴스화 작업은 웹 작업자를 가동할 때 한 번만 실행하면 됩니다.

/* Worker thread. */

let instance = null;

// Listen for incoming messages
self.addEventListener('message', async (e) => {
  // Extract the `WebAssembly.Module` from the message.
  const { integer, module } = e.data;
  const importObject = {};
  // Instantiate the Wasm module that came via `postMessage()`.
  instance = instance || (await WebAssembly.instantiate(module, importObject));
  const factorial = instance.exports.factorial;
  const result = factorial(integer);
  self.postMessage({ result });
});

완벽함: 작업이 인라인 웹 워커에서 실행되고 한 번만 로드 및 컴파일됨

HTTP 캐싱을 사용하더라도 (이상적으로) 캐시된 Web Worker 코드를 가져와서 네트워크에 접속하는 것은 비용이 많이 듭니다. 일반적인 성능 방법은 웹 워커를 인라인하여 blob: URL로 로드하는 것입니다. 그래도 인스턴스화를 위해 컴파일된 Wasm 모듈을 웹 워커에 전달해야 합니다. 웹 작업자와 기본 스레드의 컨텍스트는 동일한 JavaScript 소스 파일을 기반으로 하더라도 서로 다르기 때문입니다.

/* Main thread. */

const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));

let worker = null;

const blobURL = URL.createObjectURL(
  new Blob(
    [
      `
let instance = null;

self.addEventListener('message', async (e) => {
  // Extract the \`WebAssembly.Module\` from the message.
  const {integer, module} = e.data;
  const importObject = {};
  // Instantiate the Wasm module that came via \`postMessage()\`.
  instance = instance || await WebAssembly.instantiate(module, importObject);
  const factorial = instance.exports.factorial;
  const result = factorial(integer);
  self.postMessage({result});
});
`,
    ],
    { type: 'text/javascript' },
  ),
);

button.addEventListener('click', async (e) => {
  e.preventDefault();

  // Create the Web Worker lazily on-demand.
  if (!worker) {
    worker = new Worker(blobURL);

    // Listen for incoming messages and display the result.
    worker.addEventListener('message', (e) => {
      output.textContent = e.result;
    });
  }

  worker.postMessage({
    integer: parseInt(input.value, 10),
    module: await modulePromise,
  });
});

지연되거나 즉시 Web Worker 생성

지금까지는 모든 코드 샘플이 웹 작업자를 주문형, 즉 버튼을 누를 때 느리게 가동되었습니다. 예를 들어 앱이 유휴 상태이거나 앱의 부트스트랩 프로세스의 일부인 경우와 같이 애플리케이션에 따라 Web Worker를 더 빠르게 생성하는 것이 좋습니다. 따라서 웹 작업자 생성 코드를 버튼의 이벤트 리스너 외부로 이동합니다.

const worker = new Worker(blobURL);

// Listen for incoming messages and display the result.
worker.addEventListener('message', (e) => {
  output.textContent = e.result;
});

웹 작업자 유지 여부

한 가지 질문은 웹 작업자를 영구적으로 유지해야 하는지, 아니면 필요할 때마다 다시 만들어야 하는지입니다. 두 접근 방식 모두 가능하며 장단점이 있습니다. 예를 들어 웹 작업자를 영구적으로 유지하면 앱의 메모리 공간이 증가하고 동시 작업을 처리하기가 더 어려워질 수 있습니다. 웹 작업자에서 비롯된 결과를 요청에 다시 매핑해야 하기 때문입니다. 반면에 웹 작업자의 부트스트랩 코드는 다소 복잡할 수 있으므로 매번 새 코드를 만드는 경우 많은 오버헤드가 발생할 수 있습니다. 다행히 이는 User Timing API로 측정할 수 있습니다.

지금까지의 코드 샘플은 영구 웹 작업자 하나를 유지했습니다. 다음 코드 샘플은 필요할 때마다 새로운 Web Worker 임시를 만듭니다. 웹 작업자 종료를 직접 추적해야 합니다. 코드 스니펫은 오류 처리를 건너뛰지만 문제가 발생할 경우 성공 또는 실패 등 모든 경우에 종료해야 합니다.

/* Main thread. */

let worker = null;

const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm'));

const blobURL = URL.createObjectURL(
  new Blob(
    [
      `
// Caching the instance means you can switch between
// throw-away and permanent Web Worker freely.
let instance = null;

self.addEventListener('message', async (e) => {
  // Extract the \`WebAssembly.Module\` from the message.
  const {integer, module} = e.data;
  const importObject = {};
  // Instantiate the Wasm module that came via \`postMessage()\`.
  instance = instance || await WebAssembly.instantiate(module, importObject);
  const factorial = instance.exports.factorial;
  const result = factorial(integer);
  self.postMessage({result});
});  
`,
    ],
    { type: 'text/javascript' },
  ),
);

button.addEventListener('click', async (e) => {
  e.preventDefault();
  // Terminate a potentially running Web Worker.
  if (worker) {
    worker.terminate();
  }
  // Create the Web Worker lazily on-demand.
  worker = new Worker(blobURL);
  worker.addEventListener('message', (e) => {
    worker.terminate();
    worker = null;
    output.textContent = e.data.result;
  });
  worker.postMessage({
    integer: parseInt(input.value, 10),
    module: await modulePromise,
  });
});

데모

두 가지 데모를 사용해 볼 수 있습니다. 하나는 임시 웹 작업자(소스 코드)를, 다른 하나는 영구 웹 작업자(소스 코드)를 사용합니다. Chrome DevTools를 열고 콘솔을 확인하면 버튼 클릭부터 화면에 표시된 결과까지 걸리는 시간을 측정하는 User Timing API 로그를 볼 수 있습니다. 네트워크 탭에 blob: URL 요청이 표시됩니다. 이 예에서 임시와 영구의 시간 차이는 약 3배입니다. 실제로 사람의 눈에는 이 두 가지를 구별할 수 없습니다. 실제 앱의 결과는 다를 가능성이 높습니다.

임시 작업자가 포함된 팩토리얼 Wasm 데모 앱 Chrome DevTools가 열려 있습니다. 두 개의 blob이 있습니다. 네트워크 탭의 URL 요청과 콘솔에는 두 가지 계산 시간이 표시됩니다.

영구 작업자가 포함된 팩토리얼 Wasm 데모 앱 Chrome DevTools가 열려 있습니다. 네트워크 탭의 URL 요청은 하나의 blob이며 콘솔에 4개의 계산 타이밍이 표시됩니다.

결론

이 게시물에서는 Wasm을 처리하기 위한 몇 가지 성능 패턴을 살펴봤습니다.

  • 일반적으로 스트리밍이 아닌 메서드(WebAssembly.compile()WebAssembly.instantiate())보다 스트리밍 메서드 (WebAssembly.compileStreaming()WebAssembly.instantiateStreaming())를 사용합니다.
  • 가능하다면 성능이 많은 작업을 Web Worker에서 아웃소싱하고 Wasm 로드 및 컴파일 작업은 Web Worker 외부에서 한 번만 수행합니다. 이렇게 하면 웹 작업자는 로드와 컴파일이 WebAssembly.instantiate()를 통해 발생한 기본 스레드에서 수신하는 Wasm 모듈만 인스턴스화하면 됩니다. 즉, 웹 작업자를 영구적으로 유지하면 인스턴스를 캐시할 수 있습니다.
  • 영구 웹 작업자 한 명을 영구적으로 유지하거나 필요할 때마다 임시 웹 작업자를 만드는 것이 적절한지 신중하게 측정하세요. 또한 Web Worker를 만들기에 가장 좋은 시점이 언제인지 생각해 보세요. 메모리 소비, Web Worker 인스턴스화 기간, 동시 요청을 처리해야 하는 복잡성을 고려해야 합니다.

이러한 패턴을 고려하면 최적의 Wasm 성능을 얻을 수 있습니다.

감사의 말

이 가이드는 Andreas Haas, Jakob Kummerow, Deepti Gandluri, Alon Zakai, Françis McCabe, François Beaufort, Rachel Andrew가 검토했습니다.