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, soruna en iyi çözümü önerene kadar yaklaşımı iteratif olarak geliştirir ve her seferinde bir performans kalıbı sunar.

Varsayımlar

Yerel performansa yakın olması için WebAssembly'e (Wasm) dış kaynak olarak göndermek istediğiniz, çok yoğun CPU kullanan bir göreviniz olduğunu varsayalım. Bu kılavuzda örnek olarak kullanılan, CPU'ya yoğun görev bir sayının faktöriyel değerini hesaplar. Faktoriyel, bir tam sayının ve altındaki tüm tam sayıların çarpımıdır. Örneğin, dört sayısının faktöriyel değeri (4! olarak yazılır) 24'a (yani 4 * 3 * 2 * 1) eşittir. Sayılar hızla büyür. Ö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++ ile 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. factorial.wasm dosyasını bağımsız Wasm olarak derlemek için aşağıdaki komut 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, yoğun CPU kullanan görev için Wasm modülüne bağlı olduğunu bildiğinizden Wasm dosyasını mümkün olduğunca erken ön 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 asynkrondur 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ştirir ve 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 hızlı olduğu anlamına gelir. En kötü durumda, Web İşleyici henüz hazır değilken ana iş parçacığı 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.

Asenkron kodu Web İşleyici'nin başına taşıyarak ve aslında promise'in yerine getirilmesini beklemek yerine promise'i bir değişkende saklayarak program hemen kodun etkinlik dinleyici bölümüne geçer ve ana iş parçacığındaki hiçbir mesaj kaybolmaz. Daha sonra, etkinlik dinleyicisinde 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 İş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 (veya yalnızca yükleme ve derlemeyle ilgilenen başka bir Web Çalışanı'nda) yalnızca bir kez yüklenebilir ve derlenebilir, ardından CPU yoğun görevden sorumlu Web Çalışanı'na aktarılabilir. 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 İşleyici'nin başlatılması için 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. Web İşleyici'yi satır içi hale getirip blob: URL'si olarak yüklemek, performansı artırmaya yönelik yaygın bir yöntemdir. Bu durumda, aynı JavaScript kaynak dosyasını temel alsa bile Web Worker ve ana iş parçacığının bağlamları farklı olduğundan, derlenmiş Wasm modülünün örneklenmek üzere Web Worker'a 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 Ç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 İşleyici'yi 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 İşleyici'yi kalıcı olarak açık tutmak, uygulamanızın bellek kullanımını artırabilir ve Web İşleyici'den gelen sonuçları isteklerle eşlemeniz gerektiğinden eşzamanlı görevlerle uğraşmayı 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 kadarki kod örnekleri, bir kalıcı Web Çalışanı bulundurmuştur. 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

Denemenize sunduğumuz iki demo vardır. 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 Konsolu kontrol ederseniz düğmenin tıklanmasından ekranda gösterilen sonuca kadar geçen süreyi ölçen User Timing API günlüklerini görebilirsiniz. Ağ sekmesi, blob: URL isteklerini gösterir. Bu örnekte, geçici ve kalıcı arasındaki zamanlama farkı yaklaşık 3 kattır. Uygulamada, insan gözüyle bu durumda her ikisinin de farkı anlaşılamaz. 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 bir kural olarak, akış yöntemlerini (WebAssembly.compileStreaming() ve WebAssembly.instantiateStreaming()) akış içermeyen benzerlerine (WebAssembly.compile() ve WebAssembly.instantiate()) tercih edin.
  • Mümkünse performans açısından ağır olan görevleri bir Web Worker'a aktarın ve Wasm yükleme ve derleme işlemini yalnızca bir kez Web Worker 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.
  • Bir kalıcı Web İşleyici'yi sürekli olarak kullanmanın veya gerektiğinde geçici Web İşleyici'ler 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. Bellek tüketimi, Web İşleyicisi örneklendirme süresi ve aynı anda birden fazla istekle uğraşmak zorunda kalmanın karmaşıklığı dikkate alınmalıdır.

Bu kalıpları dikkate alırsanı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.