Wasm'a C kitaplığı kullanma

Bazen yalnızca C veya C++ kodu olarak kullanılabilen bir kitaplığı kullanmak isteyebilirsiniz. Normalde bu noktada pes edersiniz. Artık bu durum değişti. Çünkü artık Emscripten ve WebAssembly (veya Wasm) var.

Araç zinciri

Mevcut C kodlarını Wasm'a nasıl derleyeceğimi öğrenmeyi hedefledim. LLVM'nin Wasm arka ucuyla ilgili bazı söylentiler vardı. Ben de bu konuyu araştırmaya başladım. Bu şekilde basit programları derleyebilirsiniz ancak C'nin standart kitaplığını kullanmak veya birden fazla dosyayı derlemek istediğinizde sorunlarla karşılaşabilirsiniz. Bu da beni öğrendiğim en önemli derse götürdü:

Emscripten, C'den asm.js'ye dönüştürme yapan bir derleyici olsa da, Wasm'ı hedefleyecek şekilde gelişti ve dahili olarak resmi LLVM arka ucuna geçiş sürecinde. Emscripten, C'nin standart kitaplığının Wasm uyumlu bir uygulamasını da sağlar. Emscripten'i kullanın. Arka planda çok fazla iş yapar, dosya sistemini taklit eder, bellek yönetimi sağlar, OpenGL'yi WebGL ile sarmalar. Bunlar, geliştirme yaparken kendiniz deneyimlemeniz gerekmeyen birçok şeydir.

Bu durum, dosya boyutunun büyümesine yol açacak gibi görünse de (ben de öyle düşünmüştüm) Emscripten derleyicisi gereksiz her şeyi kaldırır. Denemelerimde, ortaya çıkan Wasm modülleri, içerdikleri mantığa uygun boyuttaydı. Emscripten ve WebAssembly ekipleri, gelecekte bu modülleri daha da küçültmek için çalışıyor.

Emscripten'i, web sitesindeki talimatları uygulayarak veya Homebrew'u kullanarak edinebilirsiniz. Benim gibi Docker'laştırılmış komutları seviyorsanız ve WebAssembly'yi denemek için sisteminize bir şeyler yüklemek istemiyorsanız bunun yerine kullanabileceğiniz, iyi bakımı yapılan 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 şey derleniyor

C dilinde nth 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 sizi pek şaşırtmayacaktır. C bilmeseniz bile JavaScript biliyorsanız burada neler olduğunu anlayabilirsiniz.

emscripten.h, Emscripten tarafından sağlanan bir üstbilgi dosyasıdır. Bu kütüphaneye yalnızca EMSCRIPTEN_KEEPALIVE makrosuna erişmek için ihtiyacımız var ancak çok daha fazla işlev sunuyor. Bu makro, derleyiciye kullanılmamış gibi görünse bile bir işlevi kaldırmamasını söyler. Bu makroyu atlarsak derleyici, işlevi optimize eder. Sonuçta kimse kullanmıyor.

Tüm bunları fib.c adlı bir dosyaya kaydedelim. Bunu bir .wasm dosyasına dönüştürmek için Emscripten'in emcc derleyici komutunu 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şlev hakkında daha fazla bilgiyi daha sonra vereceğiz. -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ği için 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ü yükleyip başlatır ve daha iyi bir API sağlar. Gerekirse yığın, yığın 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 (~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ükledikten sonra kullanabileceğiniz bir Module global olur. Parametreleri C dostu bir şeye dönüştürme ve sarmalanmış işlevi çağırma işlemlerini gerçekleştiren 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 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örürsünüz.

Kutsal kase: C kitaplığı derleme

Şimdiye kadar yazdığımız C kodunu Wasm'ı göz önünde bulundurarak yazdık. 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 şeylere dayanır. Emscripten, bazı sınırlamalar olsa da bu özelliklerin çoğunu sağlar.

Şimdi asıl hedefimize dönelim: WebP için Wasm'a kodlayıcı derlemek. WebP codec'inin kaynağı C dilinde yazılmıştır ve GitHub'da ve 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 encode.h içindeki WebPGetEncoderVersion() işlevini JavaScript'e göstermeyi deneyelim:

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

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

Bu işlevi çağırmak için herhangi bir parametre veya karmaşık veri yapısı gerekmediğinden, 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 üst dosyalarını nerede bulabileceğini söylememiz ve libwebp'nin ihtiyaç duyduğu tüm C dosyalarını iletmemiz gerekir. Dürüst olmak gerekirse, bulabildiğim tüm C dosyalarını verdim ve gereksiz her şeyi kaldırması için derleyiciye güvendim. Bu yöntem çok işe yaradı.

    $ 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, yeni 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>

Çıkışta doğru sürüm numarasını görürüz:

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 başlangıç olsa da gerçek bir resmi kodlamak daha etkileyici olur, değil mi? O zaman bunu yapalım.

Yanıtlamamız gereken ilk soru şu: Resmi Wasm ortamı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ürüz. Neyse ki Canvas API'de, getImageData() adlı bir yöntem var. Bu yöntem, RGBA biçiminde resim verilerini içeren bir Uint8ClampedArray döndürür:

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

Şimdi "yalnızca" verileri JavaScript alanından Wasm alanına kopyalamanız gerekir. Bunun için iki ek işlevi kullanıma sunmamız gerekir. Biri Wasm alanındaki resim için bellek ayırır, diğeri ise bu belleği tekrar serbest bırakı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 kullanı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. cwrap kullanarak işlevi JavaScript'e gösterdikten sonra bu sayıyı kullanarak arabelleğimizin başlangıcını bulabilir ve resim 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 kodlama

Resim artık Wasm ortamında kullanılabilir. Şimdi WebP kodlayıcıyı çağırıp işini yapmasını isteme zamanı. WebP dokümanlarına baktığımızda WebPEncodeRGBA mükemmel bir seçenek 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 bizim için bir çıkış arabelleği ayırır. WebP resmiyle işimiz bittiğinde WebPFree() kullanarak bu arabelleği boşaltmamız gerekir.

Kodlama işleminin sonucu, çıkış arabelleği ve uzunluğudur. C'deki işlevler, dönüş türü olarak dizilere sahip olamadığından (belleği dinamik olarak ayırmadığımız sürece) statik bir global dizi kullandım. Biliyorum, temiz C değil (aslında Wasm işaretçilerinin 32 bit genişliğinde olmasına dayanı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 hazır olduğunda kodlama işlevini çağırabilir, işaretçiyi ve resim boyutunu alabilir, kendi JavaScript-land arabelleğimize yerleştirebilir ve süreçte tahsis ettiğimiz tüm Wasm-land 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 belleği hem giriş hem de çıkış resmini barındıracak kadar artıramadığı bir hatayla karşılaşabilirsiniz:

Geliştirici Araçları konsolunda bir hatanın gösterildiği 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 yeterli.

İşte bu kadar! Bir WebP kodlayıcı derledik ve bir JPEG resmi WebP'ye dönüştürdük. İşlemin başarılı olduğunu kanıtlamak için sonuç arabelleğimizi bir blob'a dönüştürüp <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 resminizin ihtişamına bakın.

Geliştirici Araçları'nın ağ paneli ve oluşturulan resim.

Sonuç

C kitaplığının tarayıcıda çalışmasını sağlamak kolay değildir ancak genel süreci ve veri akışının nasıl çalıştığını anladıktan sonra daha kolay hale gelir ve sonuçlar şaşırtıcı olabilir.

WebAssembly; web'de işleme, hesaplama ve oyun için birçok yeni olanak sunar. Wasm'ın her şeye uygulanması gereken sihirli bir çözüm olmadığını unutmayın. Ancak bu darboğazlardan biriyle karşılaştığınızda Wasm son derece faydalı bir araç olabilir.

Bonus içerik: Basit bir şeyi zor yoldan çalıştırma

Oluşturulan JavaScript dosyasını kullanmaktan kaçınmak istiyorsanız bunu yapabilirsiniz. Fibonacci örneğine geri dönelim. 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üllerinin, siz bellek sağlamadığınız sürece çalışacak belleği yoktur. Wasm modülüne herhangi bir şey sağlamak için imports nesnesini (instantiateStreaming işlevinin ikinci parametresi) kullanırsınız. Wasm modülü, içe aktarma nesnesinin içindeki her şeye erişebilir ancak dışındaki hiçbir şeye erişemez. Emscripten tarafından derlenen modüller, kural gereği, yükleme JavaScript ortamından birkaç şey bekler:

  • İlk olarak env.memory var. Wasm modülü, tabiri caizse dış dünyadan habersizdir. Bu nedenle, çalışmak için biraz bellek gerekir. WebAssembly.Memory komutunu girin. Doğrusal belleğin (isteğe bağlı olarak büyütülebilir) bir parçasını temsil eder. Boyutlandırma parametreleri "WebAssembly sayfaları biriminde"dir. Yani yukarıdaki kod, her sayfanın boyutu 64 KiB olan 1 sayfalık bellek ayırır. maximum seçeneği sağlanmadığında, bellek teorik olarak sınırsız şekilde büyür (Chrome'un şu anda 2 GB'lık bir katı 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şlayacağı yeri tanımlar. Yığın, işlev çağrıları yapmak ve yerel değişkenler için bellek ayırmak amacıyla kullanılır. Küçük Fibonacci programımızda dinamik bellek yönetimiyle ilgili herhangi bir işlem yapmadığımız için tüm belleği yığın olarak kullanabiliriz. Bu nedenle STACKTOP = 0.