WebAssembly (Wasm) in C++ - 1a parte

di Andrea Zani, in wasm,

Per motivazioni che non voglio spiegare mi sono ritrovato a interessarmi al mondo del WebAssembly. Accantonando qualsiasi riferimento in questo post a Blazor qui cercherò di scrivere qualche informazioni sulla realizzazione di WebAssembly con Emscripten in C++.

Il WebAssemply - o Wasm - come si intuisce dal nome è codice in formato binario. Non è puro linguaggio macchina che usa la macchina in cui gira al più basso livello possibile, ma è piuttosto un bytecode in grado di girare all'interno del browser, grazie ad una specie di sandbox, alla teorica massima velocità possibile ed è compatibile con tutti i browser che lo supportano, inoltre è indipendente dal sistema operativo in cui gira il browser - a scanso di equivoci ora, nel 2022, tutti i maggiori browser lo supportano.

Il Wasm alla fine è una sequenza di zero e uno in formato binario che si può riassumere nel formato esadecimale:

20 00
50
04 7E
42 01
05
20 00
20 00
42 01
7D
10 00
7E
0B

Da cui è possibile avere questo codice in formato testuale - Wat - esempio preso da Wikipedia:

get_local 0
i64.eqz
if (result i64)
    i64.const 1
else
    get_local 0
    get_local 0
    i64.const 1
    i64.sub
    call 0
    i64.mul
end

In qualche modo ricorda il linguaggio IL usato dal Framework .Net e da Net Core per decodificare il suo bytecode.

Ovviamente si può scrivere il proprio codice in Wasm in Wat, ma perché complicarsi la vita quando si possono usare linguaggi più ad alto livello e conosciuti come, nel caso preso in considerazione per questo post, in C++?

Emscripten

Il tool di sviluppo che utilizzerò è Emscripten che utilizza LLVM per la compilazione e la trasformazione e compilazione del codice. Per il suo utilizzo è necessario installare dei tool appositi sulla macchina, sia che sia Windows, Linux, etc... coma da questa documentazione. In questo post eviterò questo passaggio e semplificherò il tutto usando Docker con la relativa immagine contenente l'SDK dei tool di Emscripten. Per i test che farò sarà necessario quindi solo Docker e un web server per contenuto statico - nel mio caso ho installato con NPM il pacchetto static-server con questo comando:

npm -g install static-server

Da console andando nella directory dove è presente il contenuto HTML e lanciare il comando static-server perché questo sia disponibile nel browser al link http://localhost:9080.

Inizio scrivendo questo codice in C++:

#include <iostream>

int main() {
    std::cout << "Hello World!\n";
}

Che, se fosse compilato con un qualsiasi compilatore C++ e trasformato in eseguibile, visualizzerebbe a schermo il classico messaggio Hello World!. Ora lo voglio compilare in emscripten in modo che sia trasformato in Wasm e uso Docker per lanciare il comando emcc:

docker run `
  --rm `
  -v ${PWD}:/src `
  emscripten/emsdk:3.1.17 `
  emcc main.cpp -o main.js

Facendo questi test sotto Windows sto usando la Powershell. Se si volesse usare la Bash è sufficiente una modifica alle parentesi al comando pwd e al codice per il multilinea e il Path:

docker run \
  --rm \
  -v $(pwd):/src \
  emscripten/emsdk:3.1.17 \
  emcc main.cpp -o main.js

Lanciato il comando, se non ci sono problemi, ritorna il prompt dei comandi. Nella directory:

$ dir
Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----         7/31/2022  10:30 AM             74 main.cpp
-a----         7/31/2022  10:32 AM         199334 main.js
-a----         7/31/2022  10:32 AM         154327 main.wasm

Sono stati creati i due file, main.js e main.wasm: il primo è il codice Javascript che sarà utilizzato come chiamante e caricherà il file Wasm dove è presente il codice compilato. Per provarlo è sufficiente usare il comando node:

$ node Main.js
Hello World!

Questo significa che il codice che finora si è scritto in nodejs utilizzato Javascript può essere scritto in C++? Sì... ma tanti auguri se si vuole provare questa strada. Se si volesse fare girare il codice qui sopra in una macchina dove non è possibile usare Wasm, si può dire al compilatore di creare solo codice Javascript con l'opzione -s WASM=0:

docker run `
  --rm `
  -v ${PWD}:/src `
  emscripten/emsdk:3.1.17 `
  emcc main.cpp -o main.js  -s WASM=0

Risultato:

$ dir
Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----         7/31/2022  10:30 AM             74 main.cpp
-a----         7/31/2022  10:37 AM        1635796 main.js

$ node Main.js
Hello World!

Interessante? Può essere, ma io ero interessato ad altro.

Web Assembly nel browser

Il comando utilizzato finora in Docker, emcc, permette la compilazione del codice e la creazione anche della pagina HTML in cui girerà il codice Wasm. E' sufficiente utilizzare il nome di una pagina HTML nel comando visto prima:

docker run `
  --rm `
  -v ${PWD}:/src `
  emscripten/emsdk:3.1.17 `
  emcc main.cpp -o main.html

Nella stessa directory ora lancio il comando static-server e apro il browser al link http://localhost:9080/Main.html:

Nella directory:

$ dir
Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----         7/31/2022  10:30 AM             74 main.cpp
-a----         7/31/2022  10:39 AM         102503 main.html
-a----         7/31/2022  10:39 AM         199334 main.js
-a----         7/31/2022  10:39 AM         154327 main.wasm

La pagina HTML carica il file Main.js che penserà poi al caricamento del file Wasm. Personalmente ho trovato il codice creato nella pagina per la maggior parte inutile quindi ho deciso di minimizzare il tutto.

Custom code

Inizio scrivendo una semplice funzione che mi ritorna una stringa:

#include <iostream>
#include <emscripten/emscripten.h>
#include <emscripten/bind.h>

std::string GetString() {
    return "Hello World! from GetString";
}
    
EMSCRIPTEN_BINDINGS(simple_example) {
    emscripten::function("GetString", &GetString);
}

Gli include utilizzati sono utilizzati le varie funzioni che saranno inserite in questo file. Bind.h viene utilizzato per poter esportare la funzione appena creata e renderla visibile all'interno del Browser a dal codice Javascript che utilizzerò per poterla chiamare, come definito nel blocco EMSCRIPTEN_BINDINGS. Provo a compilare:

docker run `
  --rm `
  -v ${PWD}:/src `
  emscripten/emsdk:3.1.17 `
  emcc main.cpp -o main.js --bind

Ho dovuto aggiungere anche l'opzione --bind avendo utilizzato la definizione del blocco degli oggetti da esportare. All'inizio di questo post avevo fatto riferimento al Wat, che è la visualizzazione testuale del codice compilato nel file Wasm. E' possibile ottenere il Wat dalla compilazione precedenete, e userò un'altra immagine Docker dove è presente il comando wasm2wat:

docker run `
  --rm `
  -v ${pwd}:/src `
  polkasource/webassembly-wabt:v1.0.11 `
  wasm2wat /src/main.wasm -o /src/main.wat

Il risultato è un file testuale di oltre 8500 righe in cui copio solo una breve porzione:

(module
  (type (;0;) (func (param i32) (result i32)))
  (type (;1;) (func (param i32)))
  (type (;2;) (func (param i32 i32)))
  (type (;3;) (func (result i32)))
  (type (;4;) (func))
  (type (;5;) (func (param i32 i32 i32) (result i32)))
  (type (;6;) (func (param i32 i32 i32)))
  (type (;7;) (func (param i32 i32) (result i32)))
  (type (;8;) (func (param i32 i32 i32 i32 i32)))
  (type (;9;) (func (param i32 i32 i32 i32 i32 i32)))
  (type (;10;) (func (param i32 i32 i32 i32)))
  (type (;11;) (func (param i32 i64 i32) (result i64)))
  (type (;12;) (func (param i32 i32 i32 i32) (result i32)))
  (type (;13;) (func (param i32 i32 i32 i32 i32) (result i32)))
  (type (;14;) (func (param i32 i32 i64 i32) (result i64)))
  (type (;15;) (func (param i32 i32 i32 i64 i64)))
  (type (;16;) (func (param i32 i32 i32 i32 i32 i32 i32)))
  (type (;17;) (func (param i32 i64 i32 i32) (result i32)))
  (import "env" "_embind_register_function" (func (;0;) (type 9)))
  (import "env" "_embind_register_void" (func (;1;) (type 2)))
  (import "env" "_embind_register_bool" (func (;2;) (type 8)))
  (import "env" "_embind_register_integer" (func (;3;) (type 8)))
  (import "env" "_embind_register_float" (func (;4;) (type 6)))
  (import "env" "_embind_register_std_string" (func (;5;) (type 2)))
  (import "env" "_embind_register_std_wstring" (func (;6;) (type 6)))
  (import "env" "_embind_register_emval" (func (;7;) (type 2)))
  (import "env" "_embind_register_memory_view" (func (;8;) (type 6)))
  (import "env" "emscripten_memcpy_big" (func (;9;) (type 6)))
  (import "env" "emscripten_resize_heap" (func (;10;) (type 0)))
  (import "env" "abort" (func (;11;) (type 4)))
  (import "wasi_snapshot_preview1" "fd_close" (func (;12;) (type 0)))
  (import "wasi_snapshot_preview1" "fd_write" (func (;13;) (type 12)))
  (import "env" "setTempRet0" (func (;14;) (type 1)))
  (import "env" "_embind_register_bigint" (func (;15;) (type 16)))
  (import "wasi_snapshot_preview1" "fd_seek" (func (;16;) (type 13)))
  (func (;17;) (type 4)
    call 153
    call 49
    call 51)
...

Utilità? Nessuna.

Ora creo il file - index.html - con il codice HTML dove voglio inserire questo WebAssembly:

<!DOCTYPE html>
<html>
    <head>
        <title>Javascript Wasm Example</title>
        <script type="text/javascript">
            var Module = {
                onRuntimeInitialized: function () {
                    console.log('Module loaded: ', Module);
                    GetString();
                }
            };
        </script>
        <script defer type="text/javascript" src="main.js"></script>
        <script defer src="my-code.js" type="text/javascript"></script>
    </head>
    <body style="text-align:center">
      <h1>Example WASM C++</h1>
      <div id="output1"></div>
      <div id="output2"></div>
      <div id="output3"></div>
    </body>
    
</html>

Con il comando di compilazione precedente sono stati creati due file: main.js e main.wasm. Il primo è incluso nel codice HTML (sarà compito suo caricare il secondo file), inoltre ho inserito un altro file Javascript - my-code.js - dove inserirò il mio codice che farà da tramite tra la pagina HTML e DOM e il Wasm. Questo è necessario perché il codice Wasm non può accedere direttamente agli oggetti della pagina e del browser (come modificare direttamente il DOM).

 Il codice Javascript incluso nella pagina carica il Modulo dal file main.js e solo quando il WebAssembly è stato caricato e istanziato è eseguito l'evento onRuntimeInitialized nel quale chiamo una mia funzione GetString presente nel file my-code.js:

function GetString() {
    const msg = Module.GetString();
    console.log(msg);
    document.getElementById('output1').innerText = msg;
}

Il metodo GetString in C++ prima compilato è presente ora nel Modulo e posso richiamarlo da Javascript (il cui risultato inserisco in un DIV all'interno della pagina. Ora avvio static-server e controllo da browser che tutto funzioni correttamente:

Incuriosito di come avviene la condivisione di oggetti più complessi come la classi, provo ad aggiungere nel file main.cpp:

class MyClass {
private:
    int _x;
public:
    MyClass(int x): _x{x} {}
    int GetX() const {
        return _x;
    }
};

Questa classe ha un costruttore che accetta un valore numerico e un metodo che ritorna queso il valore numerico. Leggendo la documentazione scopro che devo aggiungere queste informazioni al blocco EMSCRIPTEN_BINDINGS:

EMSCRIPTEN_BINDINGS(simple_example) {
    emscripten::function("GetString", &GetString);
    emscripten::class_<MyClass>("MyClass")
      .constructor<int>()
        .function("GetX", &MyClass::GetX);
}

Provo la compilazione:

docker run `
  --rm `
  -v ${PWD}:/src `
  emscripten/emsdk:3.1.17 `
  emcc main.cpp -o main.js --bind

Non ottengo errori. Ma ora devo poter istanziare questa classe da Jacascript. Nel file index.html aggiungo nel codice Javasciprt della pagina, dopo aver chiamato la funzione GetString, la chiamata alla funzine CheckClass, il cui codice inserirò in my-code.js:

function CheckClass() {
    const myclass = new Module.MyClass(10);
    try {
        const x = myclass.GetX();
        console.log(x);
        document.getElementById('output2').innerText = x;
    }
    finally {
        myclass.delete();
    }
}

Come output vedrò nella pagina e nalla console il valore corretto (numero dieci). La creazione della classe si esegue nel classico modo ma ora dobbiamo essere noi da codice a chiamare di distruttore di classe perché non sarà chiamato in automatico all'uscita dello scope.

Leggendo la documentazione scopro che è possibile aggiungere i classici Flag dei compilatori C++ come la versione e le ottimizzazioni da fare al codice compilato. Provo immediatamente ad aggiungere alcune specifiche per il C++20 come i Concept:

template<typename T>
concept IsNumeric = std::is_arithmetic_v<T>;

IsNumeric auto GetSquare(IsNumeric auto num) {
    return num * num;
}

Utilizzanto il Concept IsNumeric faccio in modo che al Template possano essere utilizzati solo tipi numerici (int, float, double, etc...). Aggiungo anche questa funzione in EMSCRIPTEN_BINDINGS specificando il tipo che utilizzerò come input:

emscripten::function("GetSquare", &GetSquare<int>);

Se volessi poter passare anche double come tipo di variabile:

emscripten::function("GetSquare", &GetSquare<double>);

Ora compilo aggiungendo le opzioni per compilazione con le specifiche del C++ 20 e con il parametro -O forzo il compilatore alla massima ottimizzazione del codice per le prestazioni:

docker run `
  --rm `
  -v ${PWD}:/src `
  emscripten/emsdk:3.1.17 `
  emcc main.cpp -o main.js  -O3 --std=c++20 --bind

Altri parametri accettati di ottimizzazione:

  • -O0 nessuna ottimizzazione
  • -O1 ottimizzazione minina
  • -O2 ...
  • -O3 ottimizzazione massima
  • -Oz ottimizzazione per la dimensione finale del codice compilato.

Aggiungo la funzione Javascript in my-code.js:

function GetSquare() {
    const msg = Module.GetSquare(10);
    console.log(msg);
    document.getElementById('output3').innerText = msg;
}

Aggiungendo la chiamata a questa funzione all'evento onRuntimeInitialized in index.html:

onRuntimeInitialized: function() {
    console.log('Module loaded: ', Module);
    GetString();
    CheckClass();
    GetSquare();
}

A proposito di opzioni per la compilazione, è presente anche una opzione per il compilatore emcc che permette di ridurre il file Javascript creato per la gestione del file Wasm nel caso questo file sia da eseguire solo da browser: -sENVIRONMENT=web, ma da alcuni test il risparmio sembra esiguo.

Conclusioni

Questa prima parte è stata leggera leggera. Non voglio mettere troppa roba in un singolo post e rimando alla seconda dove scriverò qualche altro esempio testando il C++, il passaggio di argomenti dal WebAssembly al browser, e non possono mancare dei test per misurare le prestazioni. Per ora basta: è estate pure per me.

Commenti

Visualizza/aggiungi commenti

| Condividi su: Twitter, Facebook, LinkedIn

Per inserire un commento, devi avere un account.

Fai il login e torna a questa pagina, oppure registrati alla nostra community.

Nella stessa categoria
I più letti del mese