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. Artık Emscripten ve WebAssembly (veya Wasm) sayesinde bu sorun ortadan kalktı.

Mevcut bazı C kodlarını Wasm'e nasıl derleyeceğimi öğrenmeyi hedef olarak belirledim. LLVM'ın Wasm arka ucu hakkında bazı söylentiler vardı. Bu konuyu incelemeye başladım. Bu şekilde basit programları derleyebilirsiniz. Ancak C'nin standart kitaplığını kullanmak veya hatta birden fazla dosyayı derlemek istediğinizde muhtemelen sorunla karşılaşırsınız. Bu da beni önemli bir ders almaya yönlendirdi:

Emscripten, C'den asm.js'e derleyici olarak kullanılıyordu. Ancak o zamandan beri Wasm'i hedefleyecek şekilde olgunlaştı ve dahili olarak resmi LLVM arka ucuna geçme aşamasında. Emscripten, C'nin standart kitaplığının Wasm uyumlu bir uygulamasını da sağlar. Emscripten'i kullanın. Çok sayıda gizli işlem yürütür, dosya sistemini taklit eder, bellek yönetimi sağlar, OpenGL'i WebGL ile sarar. Bunların çoğu, kendiniz geliştirmeniz gerekmeyen işlemlerdir.

Bu, gereksiz kodlar konusunda endişelenmeniz gerektiği gibi görünse de (ben kesinlikle endişelendim) Emscripten derleyicisi gereksiz her şeyi kaldırır. Denemelerde elde edilen Wasm modülleri, içerdikleri mantık için uygun boyuttadır ve Emscripten ile WebAssembly ekipleri, gelecekte bunları daha da küçültmek için çalışmaktadır.

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 şey 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 dilini bilmeseniz bile JavaScript'i biliyorsanız burada neler olduğunu anlayabilirsiniz.

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ılmamış görünse bile 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. .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 bize 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ü yükleyip başlatır ve daha iyi bir API sağlar. Gerekirse yığın, yığın alanı ve C kodu yazarken genellikle işletim sistemi tarafından sağlanması beklenen diğer işlevleri de ayarlar. Bu nedenle, JavaScript dosyası biraz daha büyüktür ve 19 KB'tır (~5 KB gzip'lenmiştir).

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 işlevini 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 sayısı olan "144" değerini 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ı.

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

Basit bir başlangıç yapmak için webp.c adlı bir C dosyası yazarak WebPGetEncoderVersion() sınıfını encode.h'dan 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ısına ihtiyaç duymadığımızdan, derlemek için libwebp'nin kaynak kodunu alıp alamayacağımızı test etmek üzere basit bir program kullanabiliriz.

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

Yeni modülümüzü yüklemek için 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üzeltme sürüm numarasını çıktıda görürüz:

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

JavaScript'ten Wasm'e resim alma

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

Yanıtlamamız gereken ilk soru şudur: Resmi Wasm alanına nasıl ekleriz? 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'de getImageData() işlevi vardır. Bu işlev, bize RGBA biçimindeki resim verilerini içeren bir Uint8ClampedArray verir:

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" JavaScript dünyasındaki verileri Wasm dünyasına kopyalamak gerekiyor. 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 ayırır. Bu nedenle, piksel başına 4 bayt ayrılı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. İşlevi cwrap kullanarak JavaScript'e açıkladıktan sonra, arabelleğin 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'de kullanılabilir. WebP kodlayıcısını 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ş 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'in hem giriş hem de çıkış resmini alacak kadar belleği 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! Bir WebP kodlayıcı derledik ve bir JPEG resmini WebP'ye kodladık. İşlemin çalıştığı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 resminin ihtişamına göz atı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. Dosyayı kendimizin yükleyip çalıştırması 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üllerine bellek sağlamadığınız sürece bu modüller çalışamaz. Bir Wasm modülüne herhangi bir şey sağlamanın yolu, instantiateStreaming işlevinin ikinci parametresi olan imports nesnesini kullanmaktır. Wasm modülü, içe aktarma 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:

  • Öncelikle env.memory var. Wasm modülü, dış dünyadan haberdar olmadığından çalışmak için biraz bellek almalıdır. 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ı birimleri cinsinden"dir. Yani yukarıdaki kod, her biri 64 KiB boyutunda olan 1 sayfa bellek ayırır. maximum seçeneği sağlanmazsa bellek teorik olarak sınırsız şekilde büyüyebilir (Chrome'da şu anda 2 GB'lık katı bir sınır vardır). Çoğu WebAssembly modülünün maksimum değer ayarlaması yapması gerekmez.
  • 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 hilesi yapmadığımız için belleğin tamamını bir yığın olarak kullanabiliriz. Bu nedenle STACKTOP = 0 değerini kullanırız.