웹 앱의 WebAssembly 성능 패턴

이 가이드에서는 WebAssembly의 이점을 활용하려는 웹 개발자를 대상으로 하며 Wasm을 사용하여 CPU 집약적인 작업을 아웃소싱하는 방법을 예시가 필요합니다. 이 가이드에서는 Wasm 모듈을 로드하여 컴파일 및 인스턴스화를 최적화할 수 있습니다. 그것은 CPU 집약적인 작업을 웹 작업자로 전환하는 것에 대해 자세히 설명하고 구현 결정을 내리는 데 도움이 될 수 있습니다. 작업자 및 작업자를 영구적으로 유지할지 아니면 필요할 때 가동할지 여부입니다. 이 접근 방식을 반복적으로 개발하고 하나의 성능 패턴을 도입합니다. 문제에 대한 최상의 해결책을 제안할 때까지

가정

CPU를 많이 집약하여 아웃소싱하려는 작업이 있다고 가정해 보겠습니다. 기본에 가까운 성능을 제공하는 WebAssembly (Wasm)를 사용합니다. 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;
}

}

나머지 부분에서 컴파일에 기반한 Wasm 모듈이 있다고 가정합니다. factorial.wasm 파일에 Emscripten을 사용한 이 factorial() 함수 모두 사용 코드 최적화 권장사항을 참고하세요. 자세한 내용은 다음을 참고하세요. 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 및 제출과 페어링된 input이 있는 form가 있습니다. button입니다. 이러한 요소는 이름에 기반하여 JavaScript에서 참조됩니다.

<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 파일을 미리 로드해야 합니다. 나 이 작업을 CORS 지원 가져오기 앱의 <head> 섹션에 표시됩니다.

<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 모듈을 컴파일하고 fetch() 같은 기본 소스 - await는 필요하지 않습니다. 가장 효율적입니다. 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를 많이 사용하는 작업으로 기본 스레드에서 실행하면 앱 전체를 차단할 수 있습니다. 일반적인 관행은 이러한 작업을 Worker를 설정할 수 있습니다.

기본 스레드 재구성

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 집약적인 작업을 수행하고 결과를 다시 기본 스레드로 보냅니다. 이 접근 방식의 문제는 다음과 같이 Wasm 모듈을 인스턴스화하는 것이 WebAssembly.instantiateStreaming()는 비동기 작업입니다. 다시 말해 코드가 선정적이라는 것을 의미합니다. 최악의 경우 이벤트가 종료될 때 기본 스레드가 Web Worker가 아직 준비되지 않았으며 Web Worker는 메시지를 수신하지 않습니다.

/* 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에서 실행되고 작업이 한 번만 로드되고 컴파일됨

static 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가 있는 메시지가 스트리밍되면 이제 웹 작업자의 코드가 WebAssembly.instantiate() instantiateStreaming() 도 있습니다. 인스턴스화된 모듈이 변수에 캐시되므로 인스턴스화 작업은 한 번 더 살펴봤습니다.

/* 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 });
});

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

HTTP 캐싱을 사용하더라도 (이상적으로) 캐시된 웹 작업자 코드와 네트워크에 접속하는 것은 비용이 많이 듭니다. 일반적인 성능 트릭은 웹 작업자를 인라인하여 blob: URL로 로드합니다. 이를 위해서는 여전히 인스턴스화를 위해 웹 작업자에 전달되도록 컴파일된 Wasm 모듈은 웹 워커와 기본 스레드의 컨텍스트는 서로 다릅니다. 소스 파일을 기반으로 하는 API입니다.

/* 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를 온디맨드 방식으로 지연 가동했습니다. 호출될 때가 있습니다 애플리케이션에 따라 웹 작업자를 더 빠르게 만들 수 있습니다. 예를 들어 앱이 유휴 상태이거나 앱 부트스트랩 프로세스의 일부입니다. 따라서 Web Worker 만들기 기능을 외부 코드를 표시할 수 있습니다.

const worker = new Worker(blobURL);

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

Web Worker 사용 여부

여러분이 스스로에게 물어볼 수 있는 한 가지 질문은 영구적으로 유지하거나 필요할 때마다 다시 만들 수 있습니다. 두 접근 방식 모두 장단점이 있습니다 예를 들어, 웹을 유지하면 영구적으로 상주하면 앱의 메모리 공간이 늘어나고 결과를 매핑해야 하므로 동시 실행 작업을 처리하기가 더 어렵습니다. 요청을 다시 보냅니다. 반면에 웹은 작업자의 부트스트랩 코드는 다소 복잡할 수 있으므로 오버헤드가 발생할 때마다 새 객체를 만들면 됩니다 다행히 이 작업은 측정하고자 하는 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를 열고 콘솔을 확인하면 사용자 버튼 클릭부터 클릭까지 걸리는 시간을 측정하는 Timing API 로그 화면에 결과를 표시합니다. '네트워크' 탭에 blob: URL이 표시됩니다. 합니다. 이 예에서 임시와 영구 간의 시간 차이는 약 3배입니다. 실제로 인간의 눈으로 보면 이 두 가지는 모두 있습니다. 실제 앱의 결과는 대부분 다를 수 있습니다.

임시 작업자가 포함된 Factorial Wasm 데모 앱 Chrome DevTools가 열려 있습니다. 두 개의 blob이 있습니다. Network 탭의 URL 요청과 콘솔에는 두 개의 계산 시간이 표시됩니다.

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

결론

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

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

이러한 패턴을 고려하면 최적의 방향으로 나아가고 있는 것입니다. Wasm 성능

감사의 말씀

가이드 검토자 안드레아스 하스, 야콥 쿠메로, 딥티 간둘리, 알론 자카이, 프랜시스 맥케이브, 프랑수아 보퍼트, 레이첼 앤드류.