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

WebAssembly'den yararlanmak isteyen web geliştiricilerini hedefleyen bu kılavuzda, çalışan bir örnek yardımıyla CPU yoğun görevlerde dış kaynak kullanımı için Wasm'dan nasıl yararlanacağınızı öğreneceksiniz. Kılavuz, Wasm modüllerini yüklemeye yönelik en iyi uygulamalardan derleme ve örneklendirmeyi optimize etmeye kadar her şeyi kapsar. CPU yoğun görevlerin Web İşçilerine kaydırılması ele alınmaktadır ve Web İşçisini ne zaman oluşturacağınızı ve onu kalıcı olarak çalışır durumda tutup tutmayacağınız, gerektiğinde de hızlandırıp eski haline getireceğiniz gibi karşılaşacağınız uygulama kararlarını inceler. Kılavuz, yaklaşımı adım adım geliştirir ve sorun için en iyi çözüm önerinceye kadar her defasında bir performans kalıbı sunar.

Varsayımlar

CPU'yu yoğun şekilde kullanan bir göreviniz olduğunu ve ana bilgisayar performansı için WebAssembly'den (Wasm) destek almak istediğinizi varsayalım. Bu kılavuzdaki örnek olarak kullanılan CPU yoğun görev, bir sayının faktöriyelini hesaplar. Faktöriyel, bir tam sayı ile altındaki tüm tam sayıların çarpımıdır. Örneğin, dördün faktöriyeli (4! olarak yazılır), 24 değerine eşittir (yani 4 * 3 * 2 * 1). Sayılar hızlı bir şekilde büyür. Örneğin, 16! değeri 2,004,189,184 olur. CPU yoğun görevlere daha gerçekçi bir örnek, barkod tarama veya kafes görüntüsünü izlemek olabilir.

Bir factorial() işlevinin yinelemeli (yinelemeli değil) uygulaması, C++'ta yazılmış aşağıdaki kod örneğinde 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, tüm kod optimizasyonu en iyi uygulamalarını kullanarak bu factorial() işlevini factorial.wasm adlı bir dosyada Emscripten ile derlemeye dayalı bir Wasm modülü olduğunu varsayalım. Bu işlemi nasıl yapacağınıza dair bilgilerinizi tazelemek için ccall/cwrap kullanarak JavaScript'ten derlenmiş C işlevlerini çağırma konusunu 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 ve gönderme button ile eşlenmiş input içeren bir form bulunur. Bu öğelere, adlarına dayalı olarak JavaScript'ten başvurulur.

<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ü kullanmadan önce yüklemeniz gerekir. Web'de bu işlem fetch() API aracılığıyla gerçekleşir. Web uygulamanızın CPU yoğun işlemlerde Wasm modülüne bağlı olduğunu bildiğinizden Wasm dosyasını mümkün olan en kısa sürede önceden yüklemeniz gerekir. Bu işlemi, uygulamanızın <head> bölümündeki CORS özellikli getirme ile yaparsınız.

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

Aslında fetch() API eşzamansızdır ve sonucu await yapmanız gerekir.

fetch('factorial.wasm');

Şimdi Wasm modülünü derleyin ve örneklendirin. WebAssembly.compile() (artı WebAssembly.compileStreaming()) ve WebAssembly.instantiate() adı verilen, cazip şekilde adlandırılmış işlevler vardır. Ancak bunların yerine WebAssembly.instantiateStreaming() yöntemi, doğrudan fetch() gibi akışlı bir temel kaynaktan Wasm modülünü derler ve buna gerek yoktur. await Bu, Wasm kodunu yüklemenin en verimli ve en optimize yoludur. Wasm modülünün bir factorial() işlevini 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 Web Çalışanı olarak değiştirme

Bu işlemi ana iş parçacığında, gerçekten CPU yoğun görevlerle birlikte yürütürseniz uygulamanın tamamını engelleme riskiyle karşı karşıya kalırsınız. Yaygın olarak bu tür görevleri bir Web Çalışanına aktarmaktır.

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

CPU yoğun görevi bir Web Çalışanı'na 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 Çalışanına gönderip çıkışı alıp görüntülemeyle 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 Worker'da çalışıyor, ancak kod müstehcen

Web Worker, Wasm modülünü örneklendirir ve bir mesaj aldıktan sonra CPU yoğun görevi gerçekleştirir ve sonucu ana iş parçacığına geri gönderir. Bu yaklaşımdaki sorun, WebAssembly.instantiateStreaming() ile bir Wasm modülünü örneklendirmenin eşzamansız bir işlem olmasıdır. Bu, kodun müstehcen olduğu anlamına gelir. En kötü durumda, ana iş parçacığı Web Çalışanı henüz hazır olmadığında veri gönderir ve Web Çalışanı 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 Worker uygulamasında çalışıyor, ancak fazladan yükleme ve derleme içeriyor olabilir

Eşzamansız Wasm modülü örneklendirmesi sorununun geçici çözümlerinden biri, Wasm modülünü yükleme, derleme ve örneklendirmeyi etkinlik işleyicinin içine taşımaktır. Ancak bu, bu çalışmanın alınan her mesajda yapılması gerektiği anlamına gelir. HTTP önbelleğe alma ve HTTP önbelleğinin derlenen Wasm bayt kodunu önbelleğe alabilmesi sayesinde bu en kötü çözüm olmasa da daha iyi bir çözüm vardır.

Eşzamansız kodu Web İşçisi'nin başına taşıyarak ve aslında sözün yerine getirilmesini beklemeyip sözü bir değişkende depolayarak, program hemen kodun etkinlik işleyici bölümüne geçer ve ana iş parçacığından gelen hiçbir mesaj kaybolmaz. Etkinlik dinleyicisinde daha sonra alacağınız söz 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 Worker'da çalışır ve yalnızca bir kez yüklenip derlenir

Statik WebAssembly.compileStreaming() yönteminin sonucu, WebAssembly.Module'e dönüşen bir vaattir. Bu nesnenin güzel bir özelliği, postMessage() kullanılarak aktarılabilmesidir. Bu, Wasm modülünün ana iş parçacığında yalnızca bir kez yüklenip derlenebileceği (hatta yalnızca yükleme ve derlemeyle ilgilenen başka bir Web İşçisi) ve daha sonra CPU yoğun görevlerden sorumlu Web Çalışanına aktarılabileceği anlamına gelir. Aşağıdaki kod bu akışı göstermektedir.

/* 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 Çalışanı tarafında, geriye yalnızca WebAssembly.Module nesnesini çıkarıp örneklendirmek kalır. WebAssembly.Module içeren mesaj yayınlanmadığından, Web İşçisindeki kod artık önceki instantiateStreaming() varyantı yerine WebAssembly.instantiate() kullanıyor. Örneklenen modül, bir değişkende önbelleğe alınır. Bu nedenle, örneklendirme çalışmasının yalnızca Web Worker başlatıldıktan sonra gerçekleşmesi 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 Çalışanı'nda çalışır ve yalnızca bir kez yüklenip derlenir

HTTP önbelleğe almada bile, (ideal olarak) önbelleğe alınan Web İşçisi kodunu edinmek ve potansiyel olarak ağa ulaşmak pahalıdır. Yaygın performans yöntemlerinden biri, Web Çalışanı'nı satır içine almak ve bunu bir blob: URL'si olarak yüklemektir. Bu işlem, aynı JavaScript kaynak dosyasına dayalı olsalar bile, Web İşçisi ve ana iş parçacığının bağlamları farklı olduğundan, derlenen Wasm modülünün örneklendirme için Web Çalışanına iletilmesi gerekir.

/* 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 İşçileri oluşturma

Şimdiye kadar tüm kod örnekleri, Web Çalışanı'nı düğmeye basıldığında geç istek üzerine çalıştırıyordu. Uygulamanıza bağlı olarak, Web Çalışanı'nı daha istekli bir şekilde oluşturmak mantıklı olabilir. Örneğin, uygulama boşta kaldığında, hatta uygulamanın önyükleme sürecinin bir parçası olarak olduğunda. Bu nedenle, Web Çalışanı 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 Çalışanı'nı tutma ya da tutmama

Kendinize sorabileceğiniz sorulardan biri, Web Çalışanı'nı kalıcı olarak tutmak mı, yoksa ihtiyacınız olduğunda yeniden oluşturmak mı istediğinizdir. Her iki yaklaşım da mümkündür ve avantajları ile dezavantajları vardır. Örneğin, bir Web Çalışanı'nı kalıcı olarak tutmak uygulamanızın bellek ayak izini artırabilir ve Web Çalışanı'ndan gelen sonuçları bir şekilde isteklerle eşlemeniz gerektiğinden eş zamanlı görevlerle çalışmayı zorlaştırabilir. Diğer yandan, Web Çalışanınızın önyükleme kodu oldukça karmaşık olabilir. Bu nedenle, her seferinde yeni bir kod oluşturursanız büyük bir yük oluşturabilirsiniz. Neyse ki bunu User Timing API ile ölçebilirsiniz.

Şu ana kadarki kod örnekleri kalıcı bir Web Çalışanı'nın kalmasını sağladı. Aşağıdaki kod örneği, gerektiğinde yeni bir Web Worker anlık görüntüsü oluşturur. Web İşçisini feshetme işlemini sizin de takip etmeniz gerektiğini unutmayın. (Kod snippet'i hata işlemeyi atlar ancak bir şeylerin yanlış gittiği her durumda başarılı veya başarısız olduğunuzu her durumda 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

Kullanabileceğiniz iki demo bulunuyor. Birinde anlık Web İşçisi (kaynak kodu) ve birinde kalıcı Web İşçisi (kaynak kodu) bulunur. Chrome Geliştirici Araçları'nı açıp Console'u kontrol ederseniz düğmenin tıklandıktan sonra ekranda gösterilen sonuca kadar geçen süreyi ölçen UserTiming API günlüklerini görebilirsiniz. Ağ sekmesinde blob: URL isteği gösterilir. Bu örnekte, geçici ve kalıcı arasındaki zaman farkı yaklaşık 3 kattır. Pratikte, bu örnekte ikisi de birbirinden ayırt edilemez. Gerçek hayatta uygulamanız için sonuçlar büyük olasılıkla farklı olacaktır.

Geçici bir çalışan içeren faktöriyel Wasm demo uygulaması. Chrome Geliştirici Araçları açık. İki blob vardır: Ağ sekmesinde URL istekleri ve Konsolda iki hesaplama zamanlaması gösterilir.

Kalıcı bir çalışan içeren faktöriyel Wasm demo uygulaması. Chrome Geliştirici Araçları açık. Ağ sekmesinde yalnızca bir blob vardır: URL isteği ve Konsol, dört hesaplama zamanlaması gösterir.

Sonuçlar

Bu gönderide, Wasm ile başa çıkmaya dair bazı performans kalıpları incelenmiştir.

  • Genel bir kural olarak, akış olmayan yöntemleri (WebAssembly.compile() ve WebAssembly.instantiate()) yerine akış yöntemlerini (WebAssembly.compileStreaming() ve WebAssembly.instantiateStreaming()) tercih edin.
  • Yapabiliyorsanız performans açısından yoğun işleri Web İşçisi için dış kaynak kullanarak yürütün ve Wasm yükleme ve derleme işlerini Web İşçisinin dışında yalnızca bir kez yapın. Bu şekilde, Web Worker'ın yalnızca yükleme ve derlemenin WebAssembly.instantiate() ile gerçekleştiği ana iş parçacığından aldığı Wasm modülünü örneklendirmesi gerekir. Bu, Web Çalışanı kalıcı olarak tutulursa örneğin önbelleğe alınabileceği anlamına gelir.
  • Tek bir kalıcı Web Çalışanı'nı sonsuza dek tutmanın mı yoksa ihtiyaç duyulduğunda anlık Web Çalışanları mı oluşturmanın mantıklı olduğunu dikkatlice ölçün. Ayrıca, web işçisini oluşturmak için en iyi zamanın ne zaman olduğunu düşünün. Dikkate alınması gereken noktalar arasında bellek tüketimi ve Web Çalışanı örneklendirme süresi olmakla birlikte, eşzamanlı isteklerle ilgilenmenin olası karmaşıklığı da göz önünde bulundurulur.

Bu kalıpları dikkate aldığınızda optimum Wasm performansı için doğru yoldasınız demektir.

Teşekkür

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