Wasm'a C kitaplığı kullanma

Bazen yalnızca C veya C++ kodu olarak kullanılabilen bir kitaplığı kullanmak istersiniz. Geleneksel yaklaşımda, vazgeçtiğiniz nokta budur. Artık değil, çünkü artık Emscripten ve WebAssembly (veya Wasm) var!

Araç zinciri

Kendime, mevcut bazı C kodlarını Wasm'da nasıl derleyeceğimi araştırdım. LLVM'nin Wasm arka ucunda bir miktar gürültü vardı ve bu konuyu araştırmaya başladım. Bu şekilde derlenecek basit programların derlenmesini sağlayabilirsiniz ancak C'nin standart kitaplığını kullanmak veya hatta birden fazla dosyayı derlemek istediğinizde büyük olasılıkla sorunlarla karşılaşırsınız. Bu, beni öğrendiğim en önemli derse getirdi:

Emscripten bir C-to-asm.js derleyicisi olmak için kullanılmış olsa da o zamandan beri Wasm'ı hedefleyecek şekilde olgunlaşmış ve resmi LLVM arka ucuna şirket içinde geçiş süreçtedir. Emscripten, C'nin standart kitaplığının Wasm ile uyumlu bir uygulamasını da sağlar. Empscripten'i kullanın. Birçok gizli iş taşır, bir dosya sistemi emülasyonu yapar, bellek yönetimi sağlar, OpenGL'yi WebGL ile sarmalar. Bunu yaparken aslında kendiniz geliştirmek zorunda kalmayacağınız pek çok şeyi barındırır.

Şişme konusunda endişelenmeniz gerekiyor gibi görünse de Emscripten derleyicisi, ihtiyaç olmayan her şeyi kaldırır. Denemelerimde, sonuçta ortaya çıkan Wasm modülleri, içerdikleri mantığa göre uygun boyutlara sahip. Emscripten ve WebAssembly ekipleri bunları gelecekte daha da küçültmek için çalışıyor.

Emscripten'ı kendi web sitelerindeki talimatları uygulayarak veya Homebrew'u kullanarak edinebilirsiniz. Benim gibi otomatikleştirilmiş komutları seviyorsanız ve yalnızca WebAssembly ile oynamak için sisteminize bir şeyler yüklemek istemiyorsanız bunun yerine kullanabileceğiniz iyi bir Docker görüntüsü vardır:

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

Basit bir şeyi derleme

C'de n. fibonacci sayısını hesaplayan bir işlev yazmaya dair neredeyse standart ö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 fonksiyonun kendisi de şaşırtıcı olmamalıdır. C'yi bilmeseniz de JavaScript'i bilmeseniz de, neler olup bittiğini anladığınızı umuyoruz.

emscripten.h, Emscripten tarafından sağlanan bir üstbilgi dosyasıdır. Yalnızca EMSCRIPTEN_KEEPALIVE makrosuna erişebildiğimiz için ihtiyacımız var, ancak bu makro çok daha fazla işlev sunuyor. Bu makro, derleyiciye bir işlevi, kullanılmamış görünse bile kaldırmamasını bildirir. Bu makroyu atlarsak derleyici, işlevi optimize eder ve artık kimse kullanmaz.

Hepsini fib.c adlı bir dosyaya kaydedelim. Bunu bir .wasm dosyasına dönüştürmek için Emscripten'in derleyici komutuna emcc dönüştürmemiz gerekir:

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

Şimdi bu komutu inceleyelim. emcc, Emscripten'in derleyicisidir. fib.c, C dosyamız. Şu ana kadar her şey yolunda. -s WASM=1, Emscripten'dan bize bir 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 bırakmasını söyler. Bu işlev hakkında daha sonra daha fazla bilgi verilecektir. -O3, derleyiciye yüksek düzeyde optimizasyon yapmasını söyler. Derleme süresini kısaltmak için daha düşük sayılar seçebilirsiniz. Ancak derleyici, kullanılmayan kodları kaldıramayabileceğinden sonuç paketleri de büyür.

Bu komutu çalıştırdıktan sonra sonuç olarak a.out.js adında bir JavaScript dosyası ve a.out.wasm adlı WebAssembly dosyası elde etmeniz gerekir. 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 güzel bir API sağlar. Gerekirse C kodu yazılırken genellikle işletim sistemi tarafından sağlanması beklenen yığını, yığını ve diğer işlevlerin ayarlanmasını da halleder. Bu nedenle, JavaScript dosyası biraz daha büyüktür ve boyutu 19 KB'tır (yaklaşık 5 KB gzip'd).

Basit bir iş çalıştırma

Modülünüzü yüklemenin ve çalıştırmanın en kolay yolu, oluşturulan JavaScript dosyasını kullanmaktır. Bu dosyayı yükledikten sonra bir Module global kullanıma sunulur. Parametreleri C uyumlu bir özelliğe dönüştüren ve sarmalanmış işlevi çağıran bir JavaScript yerel işlevi oluşturmak için cwrap kullanın. cwrap; işlev adını, dönüş türünü ve bağımsız değişken türlerini bağımsız değişken olarak şu sırayla 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" değerini görmeniz gerekir.

Kutsal hedef: Bir C kütüphanesi derleme

Şimdiye kadar, yazdığımız C kodu Wasm düşünülerek yazılmıştı. Ancak WebAssembly'nin temel kullanım amaçlarından biri, mevcut C kitaplık ekosistemini alıp geliştiricilerin bunları web'de kullanmasına olanak tanımaktır. Bu kitaplıklar genellikle C'nin standart kitaplığını, işletim sistemini, dosya sistemini ve diğerlerini kullanır. Bazı sınırlamalar olmakla birlikte, Emscripten bu özelliklerin çoğunu sağlar.

Baştaki hedefime geri dönelim: WebP'den Wasm'a bir kodlayıcı derleme. WebP codec'inin kaynağı C'de yazılmıştır ve GitHub'ın yanı sıra bazı kapsamlı API dokümanlarında da mevcuttur. 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 platformundan JavaScript'e göstermeyi deneyelim:

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

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

Bu, bu işlevi çağırmak için herhangi bir parametreye veya karmaşık veri yapısına gerek olmadığından, libwebp'nin kaynak kodunu derlemek üzere alıp alamadığımızı test etmek için kullanışlı ve basit bir programdır.

Bu programı derlemek için derleyiciye, -I işaretini kullanarak libwebp'nin başlık dosyalarını nerede bulabileceğini bildirmemiz ve ayrıca ihtiyaç duyduğu tüm libwebp dosyalarını iletmemiz gerekir. Dürüst olmak gerekirse, bulabildiğim tüm C dosyalarını ona verdim ve gereksiz her şeyi ayıklaması için derleyiciye güvendim. Harika iş çıkarmış gibi görünüyordu.

    $ 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 yepyeni modülümüzü yüklemek için sadece biraz HTML ve JavaScript gerekiyor:

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

Çıkışta düzeltilmiş sürüm numarasını göreceğiz:

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

JavaScript'ten Wasm'a bir resim alın

Kodlayıcının sürüm numarasını öğrenmek harika bir şey, ancak gerçek bir resmi kodlamak daha etkileyici olurdu, değil mi? O halde bunu yapalım.

Cevaplamamız gereken ilk soru şudur: Görüntüyü Wasm Arazisine nasıl taşıyabiliriz? libwebp'nin kodlama API'si incelendiğinde RGB, RGBA, BGR veya BGRA cinsinden bir bayt dizisi olmasını bekler. Neyse ki Canvas API'sinde getImageData() bulunur. Bu da bize RGBA'daki resim verilerini içeren bir Uint8ClampedArray sağlar:

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

Şimdiyse sadece, JavaScript ortamından Wasmland'e veri kopyalamaktan ibarettir. Bunun için iki ek işlev göstermemiz gerekiyor. Wasm arazisinin içindeki resim için bellek ayıran ve resmi tekrar serbest bırakan yöntem:

    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 ayırdığı için piksel başına 4 bayt ayrılır. malloc() tarafından döndürülen işaretçi, bu arabelleğin ilk bellek hücresinin adresidir. İşaretçi, JavaScript alanına döndürüldüğünde yalnızca bir sayı olarak değerlendirilir. İşlevi, cwrap kullanarak JavaScript'e gösterdikten sonra bu sayıyı kullanarak arabelleğimizin başlangıcını bulabilir ve görüntü verilerini kopyalayabiliriz.

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

Büyük Final: Resmi kodlayın

Resim artık Wasm land'de kullanılabilir. İşini yapması için WebP kodlayıcıyı çağırmanın zamanı geldi. WebP dokümanlarına bakıldığında WebPEncodeRGBA mükemmel bir seçim gibi görünebilir. Bu işlev, giriş resmine ve boyutlarına işaret eder. Ayrıca 0 ile 100 arasında bir kalite seçeneğine sahip olur. Ayrıca bizim için bir çıkış arabelleği ayırır. WebP görüntüsüyle işimiz bittikten sonra WebPFree() kullanarak bunu serbest bırakmamız gerekir.

Kodlama işleminin sonucu, çıkış arabelleği ve uzunluğudur. C'deki işlevler dönüş türü olarak diziye sahip olamayacağı için (belleği dinamik olarak ayırmadığımız sürece) statik bir genel diziye başvurdum. Biliyorum, temiz C değil (aslında, Wasm işaretçilerinin 32 bit genişliğinde olmasına bağlı), ama işleri basit tutmak için bunun makul bir kısayol 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];
    }

Şimdi tüm bunlar yerine getirildiğinde kodlama işlevini çağırabilir, işaretçiyi ve resim boyutunu alabilir, bunu kendi JavaScript alan arabelleğine yerleştirebilir ve süreçte ayırdığımız tüm Wasm-land tamponlarını 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 belleği hem giriş hem de çıkış görüntüsünü içerecek kadar büyütemediği bir hatayla karşılaşabilirsiniz:

Hata gösteren DevTools konsolunun ekran görüntüsü.

Neyse ki bu sorunun çözümü hata mesajında yatıyor. Derleme komutumuza -s ALLOW_MEMORY_GROWTH=1 eklememiz yeterlidir.

İşte bu kadar! Bir WebP kodlayıcı derledik ve bir JPEG görüntüsünü WebP'ye dönüştürdük. İş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 WebP görüntüsünün zaferine bakın!

DevTools&#39;un ağ panelini ve oluşturulan görüntüyü.

Sonuç

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 nasıl çalıştığını anladığınızda bunu yapmak kolaylaşır ve sonuçlar hayret verici olabilir.

WebAssembly, web'de işleme, sayı hesaplama ve oyun için birçok yeni olanak sunuyor. Wasm'ın her şeye uygulanması gereken sihirli bir değnek olmadığını unutmayın. Ancak bu engellerden biriyle karşılaştığınızda Wasm son derece faydalı bir araç olabilir.

Bonus içerik: Basit bir işi zor yoldan yürütme

Oluşturulan JavaScript dosyasını kullanmaktan kaçınmak istiyorsanız bunu yapabilirsiniz. Fibonacci örneğine dönelim. Reklamı kendimiz yükleyip çalıştırmak için aşağıdakileri 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ülleri, bellek sağlamadığınız sürece üzerinde çalışılacak bellek içermez. Bir Wasm modülünü herhangi bir şey ile sağlamak için instantiateStreaming işlevinin ikinci parametresi olan imports nesnesini kullanabilirsiniz. Wasm modülü, içe aktarma nesnesinin içindeki her şeye erişebilir ancak dışındaki diğer öğelere erişebilir. Emscripting ile derlenen modüller, kural olarak, yüklenen JavaScript ortamından birkaç şey bekler:

  • İlki, env.memory. Wasm modülü deyim yerindeyse dış dünyadan habersizdir. Bu nedenle, çalışmak için biraz belleğe ihtiyacı vardır. WebAssembly.Memory komutunu girin. İsteğe bağlı olarak büyütülebilir bir doğrusal bellek parçasını temsil eder. Boyutlandırma parametreleri "WebAssembly sayfaları birimi cinsinden"dir. Diğer bir deyişle, yukarıdaki kod 1 sayfa bellek tahsis eder ve her sayfanın boyutu 64 KiB'dir. maximum seçeneği sunulmadığında, belleğin büyümesi teorik olarak sınırsızdır (Chrome'un şu anda 2 GB'lık katı bir sınırı vardır). Çoğu WebAssembly modülünün maksimum değer ayarlamasına gerek yoktur.
  • env.STACKTOP, yığının büyümeye başlaması gereken yeri tanımlar. Yığın, işlev çağrıları yapmak ve yerel değişkenlere bellek ayırmak için gereklidir. Küçük Fibonacci programımızda dinamik bellek yönetimi maskaralıkları yapmadığımız için belleğin tamamını yığın olarak kullanabiliriz; bu nedenle STACKTOP = 0.