Wasm'a C kitaplığı kullanma

Bazen yalnızca C veya C++ kodu olarak kullanılabilen bir kitaplık kullanmak isteyebilirsiniz. Geleneksel olarak bu noktada pes edersiniz. Ancak artık Emscripten ve WebAssembly (veya Wasm) sayesinde bu durum değişti.

Araç zinciri

Mevcut bazı C kodlarını Wasm'ye nasıl derleyeceğimi öğrenmeyi hedefledim. LLVM'nin Wasm arka ucuyla ilgili bazı söylentiler vardı. Bu nedenle, bu konuyu araştırmaya başladım. Bu şekilde derlenecek basit programlar elde edebilirsiniz ancak C'nin standart kitaplığını kullanmak veya birden fazla dosyayı derlemek istediğinizde sorun yaşayabilirsiniz. Bu durum, öğrendiğim en önemli derse yol açtı:

Emscripten, eskiden C'den asm.js'ye derleyici olarak kullanılıyordu ancak o zamandan beri Wasm'ı hedefleyecek şekilde gelişti ve dahili olarak resmi LLVM arka ucuna geçiş sürecinde. Emscripten, C'nin standart kitaplığının Wasm ile uyumlu bir uygulamasını da sağlar. Emscripten'i kullanın. Bu, çok fazla gizli işi beraberinde getirir, dosya sistemini taklit eder, bellek yönetimi sağlar, OpenGL'yi WebGL ile sarmalar. Bunlar, geliştirme sürecinde kendiniz deneyimlemenize gerek olmayan birçok şeydir.

Bu, şişkinlik konusunda endişelenmeniz gerektiği anlamına geliyor gibi görünse de (ben kesinlikle endişelendim) Emscripten derleyicisi, gerekmeyen her şeyi kaldırır. Denemelerimde, ortaya çıkan Wasm modülleri, içerdiği mantığa uygun boyuttadır. Emscripten ve WebAssembly ekipleri, bu modülleri gelecekte daha da küçültmek için çalışmaktadır.

Emscripten'i web sitesindeki talimatları uygulayarak veya Homebrew'u kullanarak edinebilirsiniz. Benim gibi Docker'a alınmış komutları kullanmayı seviyorsanız ve WebAssembly ile deneme yapmak için sisteminize bir şeyler yüklemek istemiyorsanız bunun yerine kullanabileceğiniz iyi bakılan bir Docker görüntüsü var:

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

Basit bir şeyi derleme

C dilinde n. Fibonacci sayısını hesaplayan bir fonksiyon yazma örneğini 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 dilini biliyorsanız işlevin kendisi pek şaşırtıcı olmayacaktır. C bilmeseniz bile JavaScript biliyorsanız burada neler olduğunu anlayabilirsiniz.

emscripten.h, Emscripten tarafından sağlanan bir başlık dosyasıdır. Bu makroya erişmek için bu izne ihtiyacımız var ancak EMSCRIPTEN_KEEPALIVE çok daha fazla işlev sunuyor. Bu makro, derleyiciye kullanılmıyor gibi görünse bile bir işlevi kaldırmamasını söyler. Bu makroyu atlarsak derleyici işlevi optimize ederek kaldırır. Sonuçta kimse bu işlevi kullanmıyordur.

Tüm bunları fib.c adlı bir dosyaya kaydedelim. Bunu .wasm dosyasına dönüştürmek için Emscripten'in derleyici komutunu emcc kullanmamız gerekir:

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

Bu komutu inceleyelim. emcc, Emscripten'in derleyicisidir. fib.c, C dosyamızdır. Şu ana kadar her şey yolunda. -s WASM=1, Emscripten'e asm.js dosyası yerine 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şlevle ilgili daha fazla bilgiyi ilerleyen bölümlerde bulabilirsiniz. -O3, derleyiciye agresif bir şekilde optimizasyon yapmasını söyler. Derleme süresini kısaltmak için daha düşük sayılar seçebilirsiniz ancak bu durumda derleyici kullanılmayan kodu kaldırmayabileceğinden sonuçta elde edilen paketler 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üklenmesi ve başlatılmasının yanı sıra daha iyi bir API sağlanmasıyla ilgilenir. Gerekirse yığın, bellek ve C kodu yazarken işletim sistemi tarafından sağlanması beklenen diğer işlevlerin ayarlanmasını da sağlar. Bu nedenle, JavaScript dosyası biraz daha büyük olup 19 KB (~5 KB gzip'li) boyutundadı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üklediğinizde Module global bir değişkeniniz olur. Parametreleri C diline uygun bir şeye dönüştürmek ve sarmalanmış işlevi çağırmak için cwrap işlevini kullanarak JavaScript'e özgü bir işlev oluşturun. cwrap, işlev adını, dönüş türünü ve bağımsız değişken türlerini sırasıyla 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 sayısı olan "144"ü görmeniz gerekir.

Kutsal kâse: C kitaplığı derleme

Şimdiye kadar yazdığımız C kodu, Wasm göz önünde bulundurularak yazıldı. Ancak WebAssembly'nin temel kullanım alanlarından biri, mevcut C kitaplıkları ekosistemini alıp geliştiricilerin bunları web'de kullanmasına olanak tanımaktır. Bu kitaplıklar genellikle C'nin standart kitaplığına, bir işletim sistemine, bir dosya sistemine ve diğer öğelere bağlıdır. Emscripten, bazı sınırlamalar olsa da bu özelliklerin çoğunu sağlar.

İlk hedefime, yani WebP'den Wasm'ye kodlayıcı derlemeye geri dönelim. WebP codec'inin kaynağı C dilinde yazılmıştır ve API belgelerinin yanı sıra GitHub'da da mevcuttur. Bu oldukça iyi bir başlangıç noktasıdır.

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

Basit bir başlangıç için WebPGetEncoderVersion() öğesini encode.h öğesinden JavaScript'e aktarmayı deneyelim. Bunun için webp.c adlı bir C dosyası yazın:

    #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ısına ihtiyacımız olmadığından, libwebp'nin kaynak kodunu derleyip derleyemeyeceğimizi test etmek için bu basit programı kullanabiliriz.

Bu programı derlemek için derleyiciye -I işaretini kullanarak libwebp'nin başlık dosyalarını nerede bulabileceğini söylememiz ve ayrıca ihtiyacı olan tüm libwebp C dosyalarını iletmemiz gerekir. Dürüst olacağım: Bulabildiğim tüm C dosyalarını verdim ve derleyicinin gereksiz olan her şeyi kaldırmasına güvendim. Bu yöntem harika sonuç verdi.

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

Artık yepyeni modülümüzü yüklemek için yalnızca biraz HTML ve JavaScript'e 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üzeltilmiş sürüm numarasını çıkışta göreceğiz:

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

JavaScript'ten Wasm'ye resim alma

Kodlayıcının sürüm numarasını almak güzel bir şey olsa da gerçek bir görüntüyü kodlamak daha etkileyici olur, değil mi? O zaman hadi başlayalım.

Cevaplamamız gereken ilk soru şudur: Görüntüyü Wasm dünyasına nasıl aktarırız? libwebp'nin kodlama API'sine baktığımızda, RGB, RGBA, BGR veya BGRA biçiminde bir bayt dizisi beklediğini görüyoruz. Neyse ki Canvas API'de, getImageData() var. Bu, bize RGBA biçiminde görüntü verilerini içeren bir Uint8ClampedArray veriyor:

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 JavaScript dünyasındaki verileri Wasm dünyasına kopyalamak "yeterli". Bunun için iki ek işlevin kullanıma sunulması gerekir. Biri Wasm alanındaki resim için bellek ayıran, diğeri ise bu alanı tekrar boşaltan iki işlev vardır:

    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ırır. Bu nedenle piksel başına 4 bayt ayrılır. malloc() tarafından döndürülen işaretçi, 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 aktardıktan sonra, arabelleğimizin başlangıcını bulmak ve görüntü 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);

Büyük final: Resmi kodlama

Resim artık Wasm'de kullanılabilir. WebP kodlayıcıyı işini yapması için çağırmanın zamanı geldi. WebP dokümanlarına baktığımızda WebPEncodeRGBA, mükemmel bir seçim gibi görünüyor. İşlev, giriş resmine ve boyutlarına yönelik bir işaretçinin yanı sıra 0 ile 100 arasında bir kalite seçeneği alır. Ayrıca, WebP görüntüsüyle işimiz bittiğinde WebPFree() kullanarak boşaltmamı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, belleği dinamik olarak ayırmadığımız sürece dizi döndürme türüne sahip olamayacağından statik bir genel diziye başvurdum. Bunun temiz bir C olmadığını biliyorum (aslında Wasm işaretçilerinin 32 bit genişliğinde olmasından yararlanıyor), ancak işleri basit tutmak için bunun adil 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];
    }

Tüm bunlar tamamlandıktan sonra kodlama işlevini çağırabilir, işaretçiyi ve resim boyutunu alabilir, bunları kendi JavaScript alanımızdaki bir arabelleğe yerleştirebilir ve bu işlemde 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 bellek büyütemediği bir hatayla karşılaşabilirsiniz:

Bir hatayı gösteren DevTools konsolunun ekran görüntüsü.

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

İşte bu kadar! WebP kodlayıcı derledik ve bir JPEG resmini WebP'ye dönüştürdük. Çalıştığını kanıtlamak için sonuç arabelleğimizi bir blob'a dönüştürebilir ve <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 resminin ihtişamına bakın.

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

Sonuç

C kitaplığını tarayıcıda çalıştırmak kolay olmasa da genel süreci ve veri akışının nasıl çalıştığını anladığınızda bu işlem kolaylaşır ve sonuçlar etkileyici olabilir.

WebAssembly, web'de işleme, sayısal hesaplama ve oyun için birçok yeni olasılık sunuyor. Wasm'ın her şeye uygulanması gereken bir çözüm olmadığını, ancak bu darboğazlardan birine rastladığınızda Wasm'ın inanılmaz derecede faydalı bir araç olabileceğini unutmayın.

Bonus içerik: Basit bir şeyi zor yoldan yapmak

Oluşturulan JavaScript dosyasını kullanmamayı denemek isterseniz bunu yapabilirsiniz. Fibonacci örneğine dönelim. Yükleyip kendimiz ç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üllerine bellek sağlamadığınız sürece bu modüllerin çalışabileceği bir bellek yoktur. Herhangi bir şey içeren bir Wasm modülü sağlamanın yolu, instantiateStreaming işlevinin ikinci parametresi olan imports nesnesini kullanmaktır. Wasm modülü, imports nesnesinin içindeki her şeye erişebilir ancak bunun dışındaki hiçbir şeye erişemez. Emscripting tarafından derlenen modüller, yükleme JavaScript ortamından birkaç şey bekler:

  • İlk olarak env.memory var. Wasm modülü, dış dünyadan haberdar değildir. Bu nedenle, çalışmak için biraz belleğe ihtiyacı vardır. WebAssembly.Memory yazın. (İsteğe bağlı olarak büyütülebilen) doğrusal bir bellek parçasını temsil eder. Boyutlandırma parametreleri "WebAssembly sayfaları birimleriyle" ifade edilir. Bu, yukarıdaki kodun 1 sayfa bellek ayırdığı ve her sayfanın 64 KiB boyutunda olduğu anlamına gelir. maximum seçeneği sağlanmadığında bellek teorik olarak sınırsız büyür (Chrome'da şu anda 2 GB'lık bir sınır vardır). Çoğu WebAssembly modülünün maksimum değer ayarlaması gerekmez.
  • 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şkenler için bellek ayırmak üzere gereklidir. Küçük Fibonacci programımızda herhangi bir dinamik bellek yönetimi hilesi yapmadığımız için tüm belleği yığın olarak kullanabiliriz. Bu nedenle STACKTOP = 0.