Web uygulamaları için WebAssembly performans kalıpları

WebAssembly'den yararlanmak isteyen web geliştiricilerine yönelik bu kılavuzda, çalışan bir örnek yardımıyla yoğun CPU kullanan görevleri dış kaynak olarak kullanmak için Wasm'i nasıl kullanacağınızı öğreneceksiniz. Kılavuzda, Wasm modüllerini yüklemeyle ilgili en iyi uygulamalardan bunların derlenmesi ve örneklendirilmesini optimize etmeye kadar her şey ele alınmaktadır. Ayrıca, yoğun CPU kullanan görevlerin Web İşleyicilere taşınması ele alınmakta ve Web İşleyici'nin ne zaman oluşturulacağı ve kalıcı olarak etkin tutulup tutulmayacağı ya da gerektiğinde etkinleştirilip etkinleştirilmeyeceği gibi karşılaşacağınız uygulama kararları incelenmektedir. Kılavuz, yaklaşımı yinelemeli olarak geliştirir ve sorun için en iyi çözümü önerene kadar her seferinde tek bir performans kalıbı sunar.

Varsayımlar

Yerel performansa yakın olması nedeniyle, çok yoğun CPU kullanan bir görevinizi WebAssembly'e (Wasm) dış kaynak olarak göndermek istediğinizi varsayalım. Bu kılavuzda örnek olarak kullanılan CPU'yu yoğun olarak kullanan görev, bir sayının faktöriyelini hesaplar. Faktoriyel, bir tam sayının ve altındaki tüm tam sayıların çarpımıdır. Örneğin, dörtün faktöriyeli (4! olarak yazılır) 24 değerine (yani 4 * 3 * 2 * 1) eşittir. Sayılar hızla büyük hale gelir. Örneğin, 16!, 2,004,189,184 değerine sahiptir. CPU'ya yoğun bir görev örneği olarak barkod taramak veya raster görüntüyü izlemek verilebilir.

C++'da yazılmış aşağıdaki kod örneğinde, factorial() işlevinin iteratif (yineleme yerine) bir uygulaması gösterilmektedir.

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

}

Makalenin geri kalanında, bu factorial() işlevinin Emscripten ile factorial.wasm adlı bir dosyada derlenmesine dayalı bir Wasm modülü olduğunu ve kod optimizasyonuyla ilgili en iyi uygulamaların tümünün kullanıldığını varsayın. Bunun nasıl yapılacağı hakkında bilgi edinmek için ccall/cwrap kullanarak derlenmiş C işlevlerini JavaScript'den çağırma başlıklı makaleyi okuyun. Aşağıdaki komut, factorial.wasm öğesini bağımsız Wasm olarak derlemek için kullanıldı.

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

HTML'de, output ile eşleştirilmiş bir input ve gönder button içeren bir form vardır. Bu öğelere JavaScript'ten adlarına göre referans verilir.

<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');

Modülün yüklenmesi, derlenmesi ve örneklenmesi

Bir Wasm modülünü kullanabilmeniz için önce modülü yüklemeniz gerekir. Web'de bu işlem fetch() API'si aracılığıyla gerçekleşir. Web uygulamanızın CPU'yu yoğun şekilde kullanan görevler için Wasm modülüne bağlı olduğunu bildiğinizden Wasm dosyasını mümkün olduğunca erken yüklemeniz gerekir. Bunu, uygulamanızın <head> bölümünde CORS özellikli bir getirme ile yaparsınız.

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

Gerçekte fetch() API ayarsızdır ve sonucu await yapmanız gerekir.

fetch('factorial.wasm');

Ardından, Wasm modülünü derleyin ve örnekleyin. Bu görevler için WebAssembly.compile() (ve WebAssembly.compileStreaming()) ile WebAssembly.instantiate() adlı cazip bir şekilde adlandırılmış işlevler vardır ancak bunun yerine WebAssembly.instantiateStreaming() yöntemi, doğrudan fetch() gibi akışlı bir temel kaynaktan bir Wasm modülü derleyip ve örneklendirir. await gerekmez. Bu, Wasm kodunu yüklemenin en verimli ve optimize edilmiş yoludur. Wasm modülünün bir factorial() işlevi dışa aktardığını varsayarsak bu işlevi hemen kullanabilirsiniz.

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

Görevi bir web işleyicisine aktarma

Bu işlemi, gerçekten CPU yoğun görevlerle ana iş parçacığında yürütürseniz uygulamanın tamamını engelleme riskiniz vardır. Bu tür görevleri bir Web İşleyici'ye taşımak yaygın bir uygulamadır.

Ana iş parçacığının yeniden yapılandırılması

Yoğun CPU kullanan görevi bir Web İşleyici'ye taşımak için ilk adım, uygulamayı yeniden yapılandırmaktır. Ana iş parçacığı artık bir Worker oluşturur ve bunun dışında yalnızca girişi Web İşleyici'ye gönderme, ardından çıkışı alma ve görüntüleme ile ilgilenir.

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

Kötü: Görev Web Çalışanında çalışır ancak kod çok hızlıdır

Web İşleyici, Wasm modülünü örneklendirir ve bir mesaj aldıktan sonra yoğun CPU kullanan görevi gerçekleştirip sonucu ana mesaj dizisine geri gönderir. Bu yaklaşımın sorunu, WebAssembly.instantiateStreaming() ile bir Wasm modülünü örneklendirmenin asenkron bir işlem olmasıdır. Bu, kodun müstehcen olduğu anlamına gelir. En kötü durumda, ana iş parçacığı Web İşleyici henüz hazır değilken veri gönderir ve Web İşleyici iletiyi hiçbir zaman almaz.

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

Daha iyi: Görev, Web İşleyici'de çalışır ancak gereksiz yükleme ve derleme işlemleri olabilir.

Asenkron Wasm modülü oluşturma sorununun geçici çözümlerinden biri, Wasm modülü yükleme, derleme ve oluşturma işlemlerinin tamamını etkinlik dinleyicisine taşımaktır. Ancak bu, bu işlemin alınan her mesajda yapılması gerektiği anlamına gelir. HTTP önbelleğe alma ve derlenmiş Wasm bayt kodunu önbelleğe alabilen HTTP önbelleği sayesinde bu en kötü çözüm olmasa da daha iyi bir yöntem vardır.

Eşzamansız kodu Web İşçisi'nin başına taşıyıp gerçekte taahhüdün yerine getirilmesini beklemek yerine, taahhüdü bir değişkende saklayarak, program hemen kodun etkinlik işleyici bölümüne geçer ve ana iş parçacığındaki hiçbir mesaj kaybolmaz. Etkinlik işleyicinin içinde söz vermek beklenebilir.

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

İyi: Görev, Web İşleyici'de çalışır ve yalnızca bir kez yüklenir ve derlenir

Statik WebAssembly.compileStreaming() yönteminin sonucu, WebAssembly.Module değerine çözüm bulan bir sözdür. Bu nesnenin güzel özelliklerinden biri, postMessage() kullanılarak aktarılabilmesidir. Bu, Wasm modülünün ana iş parçacığında yalnızca bir kez yüklenip derlenebileceği (veya sadece yükleme ve derleme ile ilgilenen başka bir Web İşçisi olsa) ve ardından yoğun CPU kullanan görevlerden sorumlu Web İşçisine aktarılabileceği anlamına gelir. Aşağıdaki kodda bu akış gösterilmektedir.

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

Web İşleyici tarafında, WebAssembly.Module nesnesi çıkarılıp örneklendirilmelidir. WebAssembly.Module içeren mesaj aktarılmadığından Web İşleyici'deki kod artık önceki instantiateStreaming() varyantı yerine WebAssembly.instantiate() varyantını kullanıyor. Oluşturulan modül bir değişkende önbelleğe alınır. Bu nedenle, Web İşleyicisi başlatıldıktan sonra oluşturma işleminin yalnızca bir kez yapılması gerekir.

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

Mükemmel: Görev satır içi Web İşleyici'de çalışır ve yalnızca bir kez yüklenir ve derlenir

HTTP önbelleğe alma özelliği kullanılsa bile (ideal olarak) önbelleğe alınmış Web İşleyici kodunu almak ve potansiyel olarak ağa erişmek pahalıdır. Yaygın bir performans hilesi, Web Çalışanını satır içine almak ve bir blob: URL'si olarak yüklemektir. Bu durumda, aynı JavaScript kaynak dosyasına dayalı olsalar bile Web Çalışanı ve ana iş parçacığının bağlamları farklı olduğundan, derlenen Wasm modülünün örneklendirme için Web İşçisi'ne iletilmesini gerektirir.

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

Tembel veya istekli Web Çalışanı oluşturma

Şimdiye kadar tüm kod örnekleri, Web İşleyici'yi isteğe bağlı olarak, yani düğmeye basıldığında yavaşça başlatıyordu. Uygulamanıza bağlı olarak, Web İşleyici'yi daha hevesli bir şekilde oluşturmak (ör. uygulama boştayken veya hatta uygulamanın önyükleme sürecinin bir parçası olarak) yararlı olabilir. Bu nedenle, Web İşleyici oluşturma kodunu düğmenin etkinlik işleyicisinin dışına taşıyın.

const worker = new Worker(blobURL);

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

Web işleyiciyi kullanmaya devam edip etmeyeceğinizi belirleme

Web İşleyici'yi kalıcı olarak tutmanız mı yoksa ihtiyacınız olduğunda yeniden oluşturmanız mı gerektiğini kendinize sorabilirsiniz. Her iki yaklaşım da mümkündür ve avantajları ile dezavantajları vardır. Örneğin, bir Web Çalışanı'nı sürekli olarak yanınızda tutmak, Web Çalışanı'ndan gelen sonuçları isteklerle tekrar eşlemeniz gerekeceğinden, uygulamanızın bellek ayak izini artırabilir ve eşzamanlı görevlerle ilgilenmeyi zorlaştırabilir. Öte yandan, Web İşleyicinizin önyükleme kodu oldukça karmaşık olabilir. Bu nedenle, her seferinde yeni bir kod oluşturursanız çok fazla ek yük oluşabilir. Neyse ki bu metriği User Timing API ile ölçebilirsiniz.

Şimdiye kadar kod örnekleri kalıcı bir Web Çalışanı tuttu. Aşağıdaki kod örneği, gerektiğinde yeni bir Web İşleyicisi ad hoc oluşturur. Web İşleyici'yi sonlandırma işlemini kendiniz yapmanız gerektiğini unutmayın. (Kod snippet'inde hata işleme atlanır ancak bir sorun oluşması durumunda, başarılı veya başarısız tüm durumlarda işlemi sonlandırdığınızdan emin olun.)

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

Demolar

Üzerinde oynayabileceğiniz iki demo var. Biri geçici Web İşleyici (kaynak kod) ve diğeri kalıcı Web İşleyici (kaynak kod) içeren iki tane. Chrome Geliştirici Araçları'nı açıp Console'u kontrol ederseniz düğme tıklamasından ekranda görüntülenen sonuca kadar geçen süreyi ölçen UserTiming API günlüklerini görebilirsiniz. Ağ sekmesi blob: URL isteğini gösterir. Bu örnekte, geçici ve kalıcı arasındaki zamanlama farkı yaklaşık 3×3 pikseldir. Pratikte, bu örnekte her ikisi de insan gözü için ayırt edilemez. Gerçek uygulamanızla ilgili sonuçlar büyük olasılıkla farklı olacaktır.

Ad hoc Worker içeren Factorial Wasm demo uygulaması. Chrome Geliştirici Araçları açık olmalıdır. Ağ sekmesinde iki blob: URL isteği vardır ve Console iki hesaplama zamanlaması gösterir.

Kalıcı bir Worker içeren Factorial Wasm demo uygulaması. Chrome Geliştirici Araçları açık olmalıdır. Ağ sekmesinde yalnızca bir blob (URL isteği) vardır ve Console dört hesaplama zamanlaması gösterir.

Sonuçlar

Bu yayında, Wasm ile ilgili bazı performans kalıpları incelenmiştir.

  • Genel kural olarak, canlı olmayan muadilleri (WebAssembly.compile() ve WebAssembly.instantiate()) yerine akış yöntemlerini (WebAssembly.compileStreaming() ve WebAssembly.instantiateStreaming()) tercih edin.
  • Mümkünse performans açısından ağır olan görevleri bir Web Çalışanı'na aktarın ve Wasm yükleme ve derleme işlemini yalnızca bir kez Web Çalışanı dışında yapın. Bu sayede Web Worker'ın, yükleme ve derlemenin yapıldığı ana iş parçacığından aldığı Wasm modülünü WebAssembly.instantiate() ile örneklemesi yeterlidir. Diğer bir deyişle, Web Worker'ı kalıcı olarak tutarsanız örnek saklanabilir.
  • Tek bir kalıcı Web Çalışanını sürekli olarak tutmanın veya ihtiyaç duyulduğunda geçici Web İşçileri oluşturmanın mantıklı olup olmadığını dikkatlice ölçün. Ayrıca, Web İşleyici'yi oluşturmanın en uygun zamanını düşünün. Dikkate alınması gereken noktalar şunlardır: bellek tüketimi ve web çalışanı örneklendirme süresi. Ayrıca, eşzamanlı isteklerle uğraşmanın karmaşıklığı yer alır.

Bu kalıpları hesaba katarsanız optimum wasm performansı için doğru yoldasınız demektir.

Teşekkür ederiz

Bu kılavuz, Andreas Haas, Jakob Kummerow, Deepti Gandluri, Alon Zakai, Francis McCabe, François Beaufort ve Rachel Andrew tarafından incelendi.