Wasm'a C kitaplığı kullanma

Bazen yalnızca C veya C++ kodu olarak kullanılabilen bir kitaplık kullanmak isteyebilirsiniz. Genellikle bu noktada pes edersiniz. Ama artık değil, çünkü artık Emscripten ve WebAssembly (veya Wasm) var.

Araç zinciri

Mevcut bazı C kodlarını wasm'da nasıl derleyeceğimi belirlemeyi kendime hedef olarak belirledim. LLVM'ın Wasm arka ucu hakkında bazı söylentiler vardı. Bu konuyu incelemeye başladım. Bu şekilde basit programları kullanarak derleme yapabilirsiniz. Ancak C'nin standart kitaplığını kullanmak veya birden fazla dosya derlemek istediğinizde muhtemelen sorunlarla karşılaşırsınız. Bu durum, öğrendiğim en önemli dersi anlamama yol açtı:

Emscripten, eskiden C-to-asm.js derleyicisi olarak kullanılıyordu. Ancak o zamandan beri Wasm'i hedefleyecek şekilde olgunlaştı ve dahili olarak resmi LLVM arka ucuna geçiş sürecinde. Emscripten, ayrıca C'nin standart kitaplığının Wasm ile uyumlu bir uygulamasını sağlar. Emscripten'i kullanın. Birçok gizli iş taşır, bir dosya sistemi emüle eder, bellek yönetimi sağlar, OpenGL'i WebGL ile sarmalar. Bu pek çok şeyi, kendiniz için geliştirme konusunda deneyimlemeniz gerekmez.

Bu durum, şişkinlik konusunda endişelenmeniz gerekiyor gibi görünse de (kesinlikle endişeleniyorum) Emscripten derleyicisi gereksiz her şeyi kaldırır. Denemelerimde, elde ettiğim Wasm modülleri içerdikleri mantığa uygun şekilde boyutlandırılıyor ve Emscripten ile WebAssembly ekipleri gelecekte bunları daha da küçültmek için çalışıyor.

Emscripten'i web sitesinde verilen talimatları uygulayarak veya Homebrew'i kullanarak edinebilirsiniz. Benim gibi docker'a alınmış komutları seviyorsanız ve WebAssembly ile oynamak için sisteminize bir şeyler yüklemek istemiyorsanız bunun yerine kullanabileceğiniz iyi korunmuş bir Docker resmi vardır:

    $ docker pull trzeci/emscripten
    $ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>

Basit bir şeyler derleme

C'de n. Fibonacci sayısını hesaplayan bir işlev yazmayla ilgili neredeyse standart bir örneği ele alalım:

    #include <emscripten.h>

    EMSCRIPTEN_KEEPALIVE
    int fib(int n) {
      if(n <= 0){
        return 0;
      }
      int i, t, a = 0, b = 1;
      for (i = 1; i < n; i++) {
        t = a + b;
        a = b;
        b = t;
      }
      return b;
    }

C'yi biliyorsanız işlevin kendisi çok şaşırtıcı olmayacaktır. C'yi bilmeseniz de JavaScript'i bilseniz bile, burada neler olup bittiğini anlayacağınızı umuyorum.

emscripten.h, Emscripten tarafından sağlanan bir başlık dosyasıdır. Yalnızca EMSCRIPTEN_KEEPALIVE makrosuna erişebilmek için buna ihtiyacımız var ancak çok daha fazla işlev sunar. Bu makro, derleyiciye, kullanılmadığı halde görünen bir işlevi kaldırmamasını söyler. Bu makroyu atlarsak derleyici işlevi optimize eder. Sonuçta kimse bu işlevi kullanmıyordur.

Tüm bunları fib.c adlı bir dosyaya kaydedelim. Bu dosyayı .wasm dosyasına dönüştürmek için Emscripten'in derleyici komutu emcc olana dönüştürmemiz gerekir:

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c

Şimdi bu komutu inceleyelim. emcc, Emscripten'ın derleyicisidir. fib.c, C dosyamızdır. Şu ana kadar her şey yolunda. -s WASM=1, Emscripten'e asm.js dosyası yerine bir Wasm dosyası vermesini söyler. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]', derleyiciye cwrap() işlevini JavaScript dosyasında kullanılabilir durumda bırakmasını söyler. Bu işlev hakkında daha fazla bilgiyi aşağıda bulabilirsiniz. -O3, derleyiciye agresif bir şekilde optimizasyon yapmasını söyler. Derleme süresini azaltmak için daha düşük sayılar seçebilirsiniz ancak bu durumda derleyici kullanılmayan kodu kaldıramayacağından ortaya çıkan paketler de daha büyük olur.

Komutu çalıştırdıktan sonra a.out.js adlı bir JavaScript dosyası ve a.out.wasm adlı bir WebAssembly dosyası elde edersiniz. Wasm dosyası (veya "modül") derlenmiş C kodumuzu içerir ve oldukça küçük olmalıdır. JavaScript dosyası, Wasm modülümüzün yüklenip başlatılmasını ve daha iyi bir API sağlanmasını gerçekleştirir. Gerekirse C kodu yazılırken genellikle işletim sistemi tarafından sağlanması beklenen diğer işlevlerin, yığının, yığının ve diğer işlevlerin ayarlanmasıyla da ilgilenir. Bu nedenle, JavaScript dosyasının ağırlığı 19 KB (yaklaşık 5 KB gzip biçiminde) biraz daha büyüktür.

Basit bir şey çalıştırma

Modülünüzü yükleyip çalıştırmanın en kolay yolu, oluşturulan JavaScript dosyasını kullanmaktır. Bu dosyayı yükledikten sonra Module global bir değişkeniniz olur. Parametreleri C'ye uygun bir değere dönüştüren ve sarmalanmış işlevi çağıran bir JavaScript doğal işlevi oluşturmak için cwrap kullanın. cwrap, işlev adını, döndürme türünü ve bağımsız değişken türlerini şu sırayla bağımsız değişken olarak alır:

    <script src="a.out.js"></script>
    <script>
      Module.onRuntimeInitialized = _ => {
        const fib = Module.cwrap('fib', 'number', ['number']);
        console.log(fib(12));
      };
    </script>

Bu kodu çalıştırırsanız konsolda 12. Fibonacci numarası olan "144"ü görürsünüz.

Kutsal kase: C kitaplığı derleme

Şimdiye kadar yazdığımız C kodu, Wasm'i göz önünde bulundurarak yazılmıştır. Ancak WebAssembly'in temel kullanım alanlarından biri, mevcut C kitaplığı ekosistemini alıp geliştiricilerin bunları web'de kullanmasına izin vermektir. Bu kitaplıklar genellikle C'nin standart kitaplığına, işletim sistemine, dosya sistemine ve diğer öğelere dayanır. Emscripten bu özelliklerin çoğunu sağlar ancak bazı sınırlamalar vardır.

Asıl hedefime dönelim: WebP'den Wasm'e kodlayıcı derleme. WebP codec'inin kaynağı C dilinde yazılmıştır ve GitHub'da, ayrıca kapsamlı API dokümanlarında bulunabilir. Bu oldukça iyi bir başlangıç noktasıdır.

    $ git clone https://github.com/webmproject/libwebp

Basit bir başlangıç yapmak için, webp.c adlı bir C dosyası yazarak WebPGetEncoderVersion() kodunu encode.h dilinden JavaScript'e göstermeye çalışalım:

    #include "emscripten.h"
    #include "src/webp/encode.h"

    EMSCRIPTEN_KEEPALIVE
    int version() {
      return WebPGetEncoderVersion();
    }

Bu işlevi çağırmak için herhangi bir parametreye veya karmaşık veri yapılarına ihtiyaç duymadığımızdan, derlemek için libwebp'nin kaynak kodunu alıp alamayacağımızı test etmek açısından basit bir programdır.

Bu programı derlemek için derleyiciye -I işaretçisini kullanarak libwebp başlık dosyalarını nerede bulabileceğini söylememiz ve ayrıca ihtiyaç duyduğu tüm libwebp C dosyalarını iletmemiz gerekir. Dürüst olacağım: Bulabildiğimiz tüm C dosyalarını derleyiciye verdim ve gereksiz olan her şeyi kaldırması için derleyiciye güvendim. Bu yaklaşımın işe yaradığı anlaşılıyor.

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
        -I libwebp \
        webp.c \
        libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c

Şimdi şık, yeni modülümüzü yüklemek için yalnızca bazı HTML ve JavaScript'lere ihtiyacımız var:

<script src="/a.out.js"></script>
<script>
  Module.onRuntimeInitialized = async (_) => {
    const api = {
      version: Module.cwrap('version', 'number', []),
    };
    console.log(api.version());
  };
</script>

Düzeltme sürüm numarasını çıktı'da göreceğiz:

Doğru sürüm numarasını gösteren DevTools konsolunun ekran görüntüsü.

JavaScript'ten Wasm'a resim alma

Kodlayıcının sürüm numarasını almak iyi bir şey olsa da gerçek bir resmi kodlamak daha etkileyici olur, değil mi? O halde bunu yapalım.

Cevaplamamız gereken ilk soru şudur: Bir görüntüyü Wasm arazisine nasıl taşırız? libwebp'nin kodlama API'sine baktığımızda RGB, RGBA, BGR veya BGRA biçiminde bir bayt dizisi beklendiğini görüyoruz. Neyse ki Canvas API'si getImageData() sayesinde RGBA'daki resim verilerini içeren bir Uint8ClampedArray oluşturur:

async function loadImage(src) {
  // Load image
  const imgBlob = await fetch(src).then((resp) => resp.blob());
  const img = await createImageBitmap(imgBlob);
  // Make canvas same size as image
  const canvas = document.createElement('canvas');
  canvas.width = img.width;
  canvas.height = img.height;
  // Draw image onto canvas
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0);
  return ctx.getImageData(0, 0, img.width, img.height);
}

Artık "yalnızca" verilerin JavaScript alanından Wasm alanına kopyalanmasından ibarettir. Bunun için iki ek işlev göstermemiz gerekiyor. Wasm alanındaki resim için bellek ayıran ve tekrar boşaltan birer işlev:

    EMSCRIPTEN_KEEPALIVE
    uint8_t* create_buffer(int width, int height) {
      return malloc(width * height * 4 * sizeof(uint8_t));
    }

    EMSCRIPTEN_KEEPALIVE
    void destroy_buffer(uint8_t* p) {
      free(p);
    }

create_buffer, RGBA resmi için bir arabellek (yani piksel başına 4 bayt) ayırır. malloc() tarafından döndürülen işaretçi, söz konusu arabelleğin ilk bellek hücresinin adresidir. İşaretçi JavaScript alanına döndürüldüğünde yalnızca bir sayı olarak değerlendirilir. cwrap kullanarak işlevi JavaScript'e gösterdikten sonra, arabelleğimizin başlangıcını bulmak ve resim verilerini kopyalamak için bu sayıyı kullanabiliriz.

const api = {
  version: Module.cwrap('version', 'number', []),
  create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
  destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};
const image = await loadImage('/image.jpg');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
// ... call encoder ...
api.destroy_buffer(p);

Son aşama: Resmi kodlayın

Resim artık Wasm arazisinde kullanılabilir. İşini yapması için WebP kodlayıcı çağrısının zamanı geldi. WebP belgelerine bakıldığında WebPEncodeRGBA, son derece uygun görünüyor. İşlev, giriş resminin ve boyutlarının yanı sıra 0 ile 100 arasında bir kalite seçeneği alır. Ayrıca, WebP resmiyle işimiz bittiğinde WebPFree() kullanarak serbest bırakmamız gereken bir çıkış arabelleği de ayırır.

Kodlama işleminin sonucu bir çıkış arabelleği ve uzunluğudur. C'deki işlevler, döndürme türü olarak diziler kullanamadığından (hafızayı dinamik olarak ayırmadığımız sürece) statik bir genel diziye başvurdum. Bunun saf C olmadığının farkındayım (aslında Wasm işaretçilerinin 32 bit genişliğinde olmasından yararlanıyor). Ancak basitleştirmek için bu kısayolun uygun olduğunu düşünüyorum.

    int result[2];
    EMSCRIPTEN_KEEPALIVE
    void encode(uint8_t* img_in, int width, int height, float quality) {
      uint8_t* img_out;
      size_t size;

      size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);

      result[0] = (int)img_out;
      result[1] = size;
    }

    EMSCRIPTEN_KEEPALIVE
    void free_result(uint8_t* result) {
      WebPFree(result);
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_pointer() {
      return result[0];
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_size() {
      return result[1];
    }

Tüm bu işlemler tamamlandığında kodlama işlevini çağırabilir, işaretçiyi ve resim boyutunu alıp kendi JavaScript alanımızdaki bir arabelleğe koyabilir ve bu süreçte ayırdığımız tüm Wasm alan arabelleklerini serbest bırakabiliriz.

    api.encode(p, image.width, image.height, 100);
    const resultPointer = api.get_result_pointer();
    const resultSize = api.get_result_size();
    const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
    const result = new Uint8Array(resultView);
    api.free_result(resultPointer);

Resminizin boyutuna bağlı olarak, Wasm'ın hem giriş hem de çıkış resmini barındıracak kadar belleği büyütemediği bir hatayla karşılaşabilirsiniz:

Geliştirici Araçları konsolunda hatanın gösterildiği ekran görüntüsü.

Neyse ki bu sorunun çözümü hata mesajında yer alıyor. Sadece derleme komutumuza -s ALLOW_MEMORY_GROWTH=1 eklememiz gerekiyor.

İşte bu kadar! Bir WebP kodlayıcısı derledik ve bir JPEG görüntüsünü WebP'ye dönüştürdük. İşlemin işe yaradığını kanıtlamak için sonuç arabelleğimizi bir blob'a dönüştürüp bir <img> öğesinde kullanabiliriz:

const blob = new Blob([result], { type: 'image/webp' });
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img);

Yeni bir WebP imajıyla ihtişamını yaşayın.

DevTools&#39;un ağ paneli ve oluşturulan resim.

Sonuç

Bir C kitaplığının tarayıcıda çalışmasını sağlamak kolay bir iş değildir ancak genel süreci ve veri akışının işleyiş şeklini anladığınızda işler kolaylaşır ve elde edeceğiniz sonuçlar şaşırtıcı olabilir.

WebAssembly, web'de işleme, sayısal işleme ve oyun için birçok yeni olanak sunuyor. Wasm'in her şeye uygulanması gereken bir çözüm olmadığını unutmayın. Ancak bu darboğazlardan birine rastladığınızda Wasm son derece faydalı bir araç olabilir.

Bonus içerik: Basit bir işlemi zor yoldan yapma

Oluşturulan JavaScript dosyasını önlemek isterseniz bunu deneyebilirsiniz. Fibonacci örneğine dönelim. Kendimiz yükleyip çalıştırmak için şunları yapabiliriz:

<!DOCTYPE html>
<script>
  (async function () {
    const imports = {
      env: {
        memory: new WebAssembly.Memory({ initial: 1 }),
        STACKTOP: 0,
      },
    };
    const { instance } = await WebAssembly.instantiateStreaming(
      fetch('/a.out.wasm'),
      imports,
    );
    console.log(instance.exports._fib(12));
  })();
</script>

Emscripten tarafından oluşturulan WebAssembly modüllerinde, siz onlara bellek sağlamadığınız sürece kullanılacak bellek bulunmaz. Herhangi bir Wasm modülü sağlamak için instantiateStreaming işlevinin ikinci parametresi olan imports nesnesi kullanılır. Wasm modülü, içe aktarma nesnesinin içindeki her şeye erişebilir ancak dışında hiçbir şeye erişemez. Geleneksel olarak, Emscripting tarafından derlenen modüller, yüklenen JavaScript ortamından birkaç şey bekler:

  • Öncelikle env.memory var. Wasm modülü konuşmak için dış dünyadan haberdar değildir. Bu nedenle, üzerinde çalışması için bir miktar bellek alması gerekir. WebAssembly.Memory yazın. Bu, doğrusal bir bellek parçasını (isteğe bağlı olarak genişletilebilir) temsil eder. Boyutlandırma parametreleri "WebAssembly sayfası birimlerinde" yer alır. Yani yukarıdaki kod, her bir sayfanın boyutu 64 KiB olacak şekilde 1 sayfalık bellek ayırır. maximum seçeneği sunulmadığında bellek teorik olarak büyüme açısından sınırsız olur (Chrome'da şu anda kesin bir 2 GB sınırı vardır). Çoğu WebAssembly modülünün maksimum bir değer ayarlamasına gerek yoktur.
  • env.STACKTOP, yığının nereden büyümeye başlaması gerektiğini tanımlar. İşlev çağrıları yapmak ve yerel değişkenler için bellek ayırmak için yığın gerekir. Küçük Fibonacci programımızda dinamik bellek yönetimi maharetleri yapmadığımız için belleğin tamamını yığın olarak kullanabiliriz. Bu nedenle STACKTOP = 0.