WebAssembly'den yararlanmak isteyen web geliştiricilere yönelik bu rehberde, çalışan bir örnek yardımıyla CPU yoğun görevleri dış kaynaklara aktarmak için Wasm'ı nasıl kullanacağınızı öğreneceksiniz. Bu kılavuzda, Wasm modüllerini yüklemeyle ilgili en iyi uygulamalardan derleme ve örnekleme işlemlerini optimize etmeye kadar her konu ele alınmaktadır. Ayrıca, CPU yoğun görevleri Web Worker'lara taşıma konusunu ele alıyor ve Web Worker'ı ne zaman oluşturacağınız, kalıcı olarak etkin tutacağınız veya gerektiğinde başlatacağınız gibi karşılaşacağınız uygulama kararlarını inceliyor. Kılavuz, yaklaşımı yinelemeli olarak geliştirir ve soruna en iyi çözümü önermeden önce her seferinde bir performans kalıbı sunar.
Varsayımlar
Yerel performansa yakın olması nedeniyle WebAssembly'ye (Wasm) dış kaynak olarak aktarmak istediğiniz çok fazla CPU kullanan bir göreviniz olduğunu varsayalım. Bu kılavuzda örnek olarak kullanılan CPU yoğun görev, bir sayının faktöriyelini hesaplar. Faktöriyel, bir tam sayının ve kendisinden küçük tüm tam sayıların çarpımıdır. Örneğin, 4 faktöriyeli (4!
olarak yazılır) 24
'e eşittir (yani 4 * 3 * 2 * 1
). Sayılar hızla büyür. Örneğin, 16!
, 2,004,189,184
'dir. CPU yoğun görevlere daha gerçekçi bir örnek olarak barkod tarama veya raster görüntü izleme verilebilir.
factorial()
işlevinin yinelemeli (özyinelemeli olmayan) ve yüksek performanslı bir uygulaması, C++ ile 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, bu factorial()
işlevinin factorial.wasm
adlı bir dosyada Emscripten ile derlenmesine dayalı bir Wasm modülünün olduğu varsayılmaktadır. Bu modül, tüm kod optimizasyonuyla ilgili en iyi uygulamaları kullanır.
Bu işlemi nasıl yapacağınızı hatırlamak için ccall/cwrap kullanarak derlenmiş C işlevlerini JavaScript'ten çağırma başlıklı makaleyi okuyun.
factorial.wasm
, 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, form
ile input
, output
ile eşleştirilmiş ve bir gönder button
vardır. Bu öğelere, adlarına göre JavaScript'ten 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 örneklendirilmesi
Wasm modülünü kullanabilmek için yüklemeniz gerekir. Web'de bu işlem fetch()
API'si aracılığıyla gerçekleşir. Web uygulamanızın CPU yoğun görev için Wasm modülüne bağlı olduğunu bildiğinizden Wasm dosyasını mümkün olduğunca erken önceden yüklemeniz gerekir. Bunu, uygulamanızın <head>
bölümünde CORS etkin bir getirme işlemiyle yaparsınız.
<link rel="preload" as="fetch" href="factorial.wasm" crossorigin />
Gerçekte, fetch()
API'si eşzamansı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()
(artı WebAssembly.compileStreaming()
) ve WebAssembly.instantiate()
gibi cazip adlandırılmış işlevler vardır ancak bunun yerine fetch()
gibi akışla aktarılan temel bir kaynaktan doğrudan bir Wasm modülünü derleyen ve örneğini oluşturan WebAssembly.instantiateStreaming()
yöntemi kullanılır. 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 Worker'a kaydırma
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şılaşırsınız. Bu tür görevleri Web Worker'a kaydırmak yaygın bir uygulamadır.
Ana iş parçacığının yeniden yapılandırılması
CPU yoğun görevleri bir Web Worker'a taşımak için ilk adım, uygulamayı yeniden yapılandırmaktır. Ana iş parçacığı artık bir Worker
oluşturuyor ve bunun dışında yalnızca girişi Web Worker'a gönderme, ardından çıkışı alma ve görüntüleme işlemlerini yapıyor.
/* 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 yarışa giriyor
Web Worker, Wasm modülünü başlatır ve bir mesaj aldığında CPU yoğun görevini gerçekleştirip sonucu ana iş parçacığına geri gönderir.
Bu yaklaşımdaki sorun, WebAssembly.instantiateStreaming()
ile bir Wasm modülünün oluşturulmasının eşzamansız bir işlem olmasıdır. Bu, kodun yarış koşullarına neden olduğu anlamına gelir. En kötü durumda, ana iş parçacığı, Web Worker henüz hazır değilken veri gönderir ve Web Worker 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'da çalışır ancak yükleme ve derleme gereksiz olabilir.
Asenkron Wasm modülü oluşturma sorununu çözmek için Wasm modülü yükleme, derleme ve oluşturma işlemlerini olay dinleyiciye taşıyabilirsiniz. Ancak bu durumda, bu işlemlerin her alınan mesajda yapılması gerekir. HTTP önbelleğe alma ve derlenmiş Wasm bayt kodunu önbelleğe alabilen HTTP önbelleği ile bu, en kötü çözüm olmasa da daha iyi bir yol var.
Asenkron kodu Web Worker'ın başına taşıyarak ve sözün yerine getirilmesini beklemek yerine sözü bir değişkende saklayarak program, kodun etkinlik işleyici kısmına hemen geçer ve ana iş parçacığından gelen hiçbir mesaj kaybolmaz. Etkinlik dinleyicisinin içinde sözün tamamlanması 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
ile sonuçlanan bir sözdür.
Bu nesnenin güzel bir özelliği, postMessage()
kullanılarak aktarılabilmesidir.
Bu, Wasm modülünün ana iş parçacığında (veya tamamen yükleme ve derleme ile ilgili başka bir Web Worker'da) yalnızca bir kez yüklenip derlenebileceği ve ardından CPU yoğun görevden sorumlu Web Worker'a 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 Worker tarafında ise yalnızca WebAssembly.Module
nesnesini ayıklayıp örneklemek gerekir. WebAssembly.Module
içeren mesaj yayınlanmadığından Web Worker'daki 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, örnekleme işlemi yalnızca Web Worker başlatılırken bir kez gerçekleştirilir.
/* 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 Worker'da çalışır ve yalnızca bir kez yüklenip derlenir.
HTTP önbelleğe alma işlemiyle bile (ideal olarak) önbelleğe alınmış Web Worker kodunu almak ve ağa erişmek maliyetlidir. Yaygın bir performans hilesi, Web Worker'ı satır içi olarak eklemek ve blob:
URL'si olarak yüklemektir. Web Worker ve ana iş parçacığının bağlamları aynı JavaScript kaynak dosyasına dayalı olsa bile farklı olduğundan, derlenmiş Wasm modülünün örnekleme için 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,
});
});
Web çalışanı oluşturma (tembel veya istekli)
Şimdiye kadar tüm kod örnekleri, Web Worker'ı yalnızca gerektiğinde (ör. düğmeye basıldığında) başlatıyordu. Uygulamanıza bağlı olarak, Web Worker'ı daha hızlı bir şekilde oluşturmak mantıklı olabilir. Örneğin, uygulama boşta olduğunda veya uygulamanın başlatma sürecinin bir parçası olarak. Bu nedenle, Web Worker 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 Worker'ı saklayıp saklamama
Kendinize sorabileceğiniz sorulardan biri, Web Worker'ı kalıcı olarak tutmanız mı yoksa ihtiyacınız olduğunda yeniden oluşturmanız mı gerektiğidir. Her iki yaklaşım da mümkündür ve avantajları ile dezavantajları vardır. Örneğin, bir Web Worker'ı kalıcı olarak tutmak, uygulamanızın bellek ayak izini artırabilir ve eşzamanlı görevlerle uğraşmayı zorlaştırabilir. Bunun nedeni, Web Worker'dan gelen sonuçları isteklerle bir şekilde yeniden eşlemeniz gerekmesidir. Diğer yandan, Web Worker'ınızın başlatma kodu oldukça karmaşık olabilir. Bu nedenle, her seferinde yeni bir kod oluşturursanız çok fazla ek yük olabilir. Neyse ki bu durumu User Timing API ile ölçebilirsiniz.
Şimdiye kadarki kod örneklerinde kalıcı bir Web Worker kullanıldı. Aşağıdaki kod örneği, gerektiğinde yeni bir Web Worker'ı geçici olarak oluşturur. Web Worker'ı sonlandırmayı kendiniz takip etmeniz gerektiğini unutmayın. (Kod snippet'i hata işlemeyi atlar ancak bir sorun olması durumunda, başarılı veya başarısız tüm durumlarda sonlandırmayı unutmayın.)
/* 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
Deneyebileceğiniz iki demo vardır. Birinde
geçici Web Worker
(kaynak kodu), diğerinde ise
kalıcı Web Worker
(kaynak kodu) bulunur.
Chrome Geliştirici Araçları'nı açıp Konsol'u kontrol ederseniz düğme tıklamasından ekranda gösterilen sonuca kadar geçen süreyi ölçen User Timing API günlüklerini görebilirsiniz. Ağ sekmesinde blob:
URL istekleri gösterilir. Bu örnekte, geçici ve kalıcı arasındaki zamanlama farkı yaklaşık 3 katıdır. Uygulamada, bu durumda her ikisi de insan gözüyle ayırt edilemez. Gerçek hayattaki kendi uygulamanızın sonuçları büyük olasılıkla farklılık gösterecektir.
Sonuçlar
Bu yayında, Wasm ile ilgili bazı performans kalıpları ele alındı.
- Genel bir kural olarak, akış yöntemlerini (
WebAssembly.compileStreaming()
veWebAssembly.instantiateStreaming()
) akış olmayan yöntemlere (WebAssembly.compile()
veWebAssembly.instantiate()
) tercih edin. - Mümkünse Web Worker'da performansı yoğun olan görevleri dış kaynaklara yaptırın ve Wasm yükleme ile derleme işlemlerini Web Worker dışında yalnızca bir kez yapın. Bu sayede, Web Worker'ın yalnızca yükleme ve derleme işlemlerinin
WebAssembly.instantiate()
ile yapıldığı ana iş parçacığından aldığı Wasm modülünü oluşturması gerekir. Bu da Web Worker'ı kalıcı olarak tutarsanız örneğin önbelleğe alınabileceği anlamına gelir. - Sürekli olarak tek bir kalıcı Web Worker kullanmanın mı yoksa gerektiğinde geçici Web Worker'lar oluşturmanın mı daha mantıklı olduğunu dikkatlice değerlendirin. Ayrıca, Web Worker'ı oluşturmak için en uygun zamanı da düşünün. Bellek tüketimi, Web Worker başlatma süresi ve eşzamanlı isteklerle uğraşmak zorunda kalmanın karmaşıklığı dikkate alınması gereken faktörler arasındadır.
Bu kalıpları dikkate alırsanız optimum Wasm performansı elde etme yolunda doğru adımlar atmış olursunuz.
Teşekkür
Bu kılavuz; Andreas Haas, Jakob Kummerow, Deepti Gandluri, Alon Zakai, Francis McCabe, François Beaufort ve Rachel Andrew tarafından incelenmiştir.