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

di Andrea Zani, in wasm,

Proseguo da qui dopo i primi test per la compilazione in Wasm di codice scritto in C++. Riassumendo: ogni compilazione crea un Modulo che può essere caricato nella pagina HTML e può rendere pubblici dei metodi che potranno poi essere invocati dal proprio codice Javascript. Un palese vantaggio a differenza di altre tecnologie è la dimensione dei file compilati creati che sono di pochi decine di KB per il file Javascript che fa da Proxy, e il file Wasm che con progetti anche più complessi può pesare poche centinaia di KB. Gli svantaggi? Presenti, alcuni li mostrerò in questo post.

E' il momento di completare quanto scritto nello scorso post e vedere come fare per permettere al codice Wasm di interagire con la pagina e il DOM.

Da C++ a DOM (con truffa)

Ora scrivo questo codice in C++ che ritorna la data e ora attuali:

std::string GetDateTime() {
    auto now = std::chrono::system_clock::now(); 
    auto in_time_t = std::chrono::system_clock::to_time_t(now); 

    std::stringstream s{}; 
    s << std::put_time(std::localtime(&in_time_t), "%Y-%m-%d %H:%M:%S %z"); 
    return s.str(); 
}

void GetDate() {
    std::string w = GetDateTime();
    const std::string finalText = "SetDate('" + w + "');";
    emscripten_run_script(finalText.c_str());
}
   
EMSCRIPTEN_BINDINGS(simple_example) {
    emscripten::function("GetDate", &GetDate);
}

La funzione GetDate la richiamo dal codice Javascript della pagina, e la registro nella funzione EMSCRIPTEN_BINDINGS. Nel codice di questa funzione sono presenti due righe di codice che, presa la stringa contenente le informazioni volute, le inserisce come parametro in una funzione SetDate, quindi questa stringa viene passata come parametro a emscripten_run_script. Questa funzione fa da tramite da il WebAssembly e la pagina HTML ma, come detto nello scorso post, non è possibile utilizzare direttamente gli oggetti della pagina, ed ecco quindi la truffa: viene richiamata la funziona Javascript SetDate che scriverà effettivamente l'informazione nel browser. In my-code.js:

const bt1 = document.getElementById('bt1');
const spanDate = document.getElementById('spanDate');

bt1.addEventListener('click', (e) => {
    e.preventDefault();
    Module.GetDate();
});

function SetDate(date) {
    spanDate.innerText = date;
}

E infatti caricata nel browser questa pagina fa quanto promesso:

Fetch in C++

Altra pratica nelle applicazioni client side nel browser è il download di informazioni con Api Rest. E' possibile farlo anche con le WebAssembly anche se la procedura è un po' più complessa. Nella mia pagina creo due Button: il primo che esegue in download di un file JSON esistente, il secondo che proverà a richiedere un file JSON non presente sul server.

Primo passo è creare nella directory un file fittizio JSON: data.json:

{"name":"az"}

Ed ecco le funzioni in C++ che, richiamate da Javascript, eseguiranno il Fetch del file:

void downloadSucceeded(emscripten_fetch_t *fetch) {
    printf("Finished downloading %llu bytes from URL %s.\n", fetch->numBytes, fetch->url);

    std::string s(fetch->data, fetch->numBytes);
    std::cout << s <<"\n";
    const std::string finalText = "WriteFetchResult1('" + s + "');";
    emscripten_run_script(finalText.c_str());

    emscripten_fetch_close(fetch); // Free data associated with the fetch.
}

void downloadFailed(emscripten_fetch_t *fetch) {
    printf("Downloading %s failed, HTTP failure status code: %d.\n", fetch->url, fetch->status);
    std::string s = std::to_string(fetch->status);
    const std::string finalText = "WriteFetchResult2('" + s + "');";
    emscripten_run_script(finalText.c_str());

    emscripten_fetch_close(fetch); // Also free data on failure.
}

void LoadFile1() {
    emscripten_fetch_attr_t attr;
    emscripten_fetch_attr_init(&attr);
    strcpy(attr.requestMethod, "GET");
    attr.attributes = EMSCRIPTEN_FETCH_LOAD_TO_MEMORY | EMSCRIPTEN_FETCH_PERSIST_FILE;
    attr.onsuccess = downloadSucceeded;
    attr.onerror = downloadFailed;
    emscripten_fetch(&attr, "data.json");
}

LoadFile1 crea l'oggetto emscripten_fetch_attr_t che eseguirà il download del file in modalità asincrona. Ad esso collego due eventi, il primo per gestire il download corretto della risorsa esterna - onsuccess - mentre la seconda permette la gestione in caso di errore - onerror. Nel browser il risultato dopo aver cliccato su entrambi i Button:

Per poter compilare il codice C++, usando la funzione Fetch, è necessario aggiungere il parametro -s FETCH=1 al compilatore:

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

Main Loop

Finora ho solo mostrato un WebAssembly passivo: esso veniva utilizzato solo come contenitore di classi o metodi da richiamare dalla pagina HTML e dal suo Javascript. Ma nelle applicazioni più complesse dove essa è colonna portante dell'applicazione che gira all'interno del browser dove è gestito da lui - dalla grafica, al sonoro, alla gestione degli eventi, etc... - si possono gestire eventi interni. Cuore è il Main Loop nel quale è possibile inserire il proprio codice per la gestione di effetti grafici più tranquilli di un'applicazione 2D o più impegnativi nel caso di applicazioni 3D gestiti con WebGL.

Il suo utilizzo è semplice e si basa nell'esecuzione di una sola riga di codice in cui viene passata la funzione che dovrà essere richiamata n volte al secondo. Nel mio codice C++ aggiungo:

void StartMainLoop() {
    emscripten_set_main_loop(WriteMessageInBrowser,
        0,
        false);
}

WriteMessageInBrowser è la funziona C++ che sarà richiamata. Il secondo parametro contenente il valore zero è il numero di volte al secondo che sarà chiamata la funzione, con valore zero si userà il valore di default del browser - sessanta - per diverse esigenze si può configurare il numero desiderato. L'ultimo parametro, impostato a false, viene utilizzato per simulare il loop: nel caso di valore false l'elaborazione del codice continua con il resto del codice presente dopo quel comando, altrimenti, se impostato a true, viene bloccata l'esecuzione.

Ecco il codice che utilizzerò nella mia demo:

int _counter = 0;
void WriteMessageInBrowser() {
    std::string s = std::to_string(_counter);
    const std::string finalText = "WriteInfoMainLoop('" +  s + "');";
    emscripten_run_script(finalText.c_str());
    _counter += 1;
}

void StartMainLoop() {
    emscripten_set_main_loop(WriteMessageInBrowser,
        0,
        false);
}

void StopMainLoop() {
    emscripten_cancel_main_loop();
}

Così come nel primo esempio, utilizzo il trucco di richiamare una funziona Javascript della mia pagina che inserirà il valore della variabile _counter nel browser. Inoltre ho inserito due funzioni, uno per avviare il Main Loop e la seconda funzione per fermarlo. Ecco il risultato:

Ok basta, è ora di fare sul serio.

Benchmark: Javascript vs Wasm

Personalmente è la prima cosa che ho controllato quando ho iniziato a interessarmi al WebAssembly. E ovviamente non potevano mancare un paio di test riguardo. Nella demo che ho creato ho inserito due banali test per l'ordinamento con il semplice e con poche esigenze di prestazioni con il metodo Bubble Sort in Javascript e C++, quindi un secondo test con il più prestazionale Quick Sort.

Partendo dall'esempio in Javascript, inizio a misurare il tempo alla creazione di un Array di 80.000 numeri random. Quindi questo Array viene passato ad una funzione per l'ordinamento e ritornato l'Array ordinato. Solo a questo punto viene calcolato il tempo di esecuzione. Il codice è il seguente:

...
    const startTime = performance.now()
    
    const arr = GetRandomNumbers(numbersElements, numbersElements);    
    const arrOrdered = OrderArrayBubbleSortJs(arr);
    
    const endTime = performance.now()
    const time = endTime - startTime;
    spanBubbleSortJs.innerText = `${time.toFixed(2)} ms`;
...
function GetRandomNumbers(arrayLength, maxValue) {
    const arr=[];
    for (let i=0; i<arrayLength; i++) {
        arr[i] = Math.floor(Math.random() * maxValue) + 1;
    }

    return arr;
}

function OrderArrayBubbleSortJs(arr) {
    const length=arr.length;
    for (let x = 0; x<length-1; x++) {
        for (let y=x+1; y<length; y++) {
            if (arr[x] > arr[y]) {
                const tmp = arr[x];
                arr[x] = arr[y];
                arr[y]=tmp;
            }
        }
    }

    return arr;
}

Stessa cosa con il codice in C++ anche se il calcolo del tempo di esecuzione viene fatto da Javascript:

...
    const startTime = performance.now()

    const arr = Module.GetBubbleSort(numbersElements);
    
    const endTime = performance.now()
    const time = endTime - startTime;
    spanBubbleSortWasm.innerText = `${time.toFixed(2)} ms`;
...

template<typename T>   
concept IsComparable = requires(T t)   
{   
    std::is_arithmetic_v<T>;
    t <=> t; 
}; 

template<IsComparable T>
void BubbleSortOrder(std::vector<T>& data)  {
    auto size = data.size();
    for (auto x = 0; x < size - 1; ++x) {
        for (auto y = x + 1; y < size; ++y) {
            if (data[x] > data[y]) {
                std::swap(data[x], data[y]);
            }
        }
    }
}

template<IsComparable T>
std::vector<T> GetBubbleSort(int size) {
    auto arr = std::vector<T>(size);
    srand (time(NULL));
    for (auto i = 0; i < size; ++i) {
        auto n = rand() % size + 1;
        arr[i] = n;
    }

    BubbleSortOrder(arr);
    return arr;
}

A parte l'uso dei concept nel Template per il controllo di tipo con l'utilizzo del Three-way comparison, qui c'è una ulteriore modifica nel codice. Non ho utilizzato l'Array classico presente nel linguaggio, spiegherò più avanti il motivo, ma un oggetto di tipo Vector. Per fare riconoscere quest'oggetto a Javascript ho dovuto aggiungere nella sezione EMSCRIPTEN_BINDINGS la sua definizione:

emscripten::register_vector<int>("vector<int>");

E' il momento di controllare i dati oggettivi. I tempi qui presenti sono delle media eseguite su un computer con CPU Quad core su due tipi di browser: Firefox e Chrome.

Firefox Chrome
Bubble Sort Javascript 20,13s 10,345s
Bubble Sort Wasm 9,875s 10,107s

La prima cosa che emerge è la bontà delle prestazioni del motore Javascript di Chrome che equivale, con poca differenza, con la versione in WebAssembly - e leggendo come funziona il motore di Chrome si può capire il perché.

Non rimane che testare il codice in versione per l'ordinamento Quick Sort sia in Javascript che WebAssembly. Ecco la versione Javascript con il link di riferimento dove ho preso il codice di esempio:

// https://www.guru99.com/quicksort-in-javascript.html
function swap(items, leftIndex, rightIndex){
    var temp = items[leftIndex];
    items[leftIndex] = items[rightIndex];
    items[rightIndex] = temp;
}

function partition(items, left, right) {
    var pivot   = items[Math.floor((right + left) / 2)], //middle element
        i       = left, //left pointer
        j       = right; //right pointer
    while (i <= j) {
        while (items[i] < pivot) {
            i++;
        }
        while (items[j] > pivot) {
            j--;
        }
        if (i <= j) {
            swap(items, i, j); //sawpping two elements
            i++;
            j--;
        }
    }
    return i;
}

function quickSort(items, left, right) {
    var index;
    if (items.length > 1) {
        index = partition(items, left, right); //index returned from partition
        if (left < index - 1) { //more elements on the left side of the pivot
            quickSort(items, left, index - 1);
        }
        if (index < right) { //more elements on the right side of the pivot
            quickSort(items, index, right);
        }
    }
    return items;
}

Ed ecco la versione in C++:

template<IsComparable T>
int Partition(std::vector<T> &v, int start, int end) {
  
  int pivot = end;
  int j = start;
  for(int i=start;i<end;++i){
    if(v[i]<v[pivot]){
      std::swap(v[i],v[j]);
      ++j;
    }
  }
  std::swap(v[j],v[pivot]);
  return j;
  
}

// https://slaystudy.com/c-vector-quicksort/
template<IsComparable T>
void Quicksort(std::vector<T> &v, int start, int end ) {

  if(start<end){
    int p = Partition(v,start,end);
    Quicksort(v,start,p-1);
    Quicksort(v,p+1,end);
  }
  
}

template<IsComparable T>
std::vector<T> GetQuickSort(int size) {
    auto arr = std::vector<int>(size);
    srand (time(NULL));
    for (auto i = 0; i < size; ++i) {
        auto n = rand() % size + 1;
        arr[i] = n;
    }

    Quicksort(arr, 0, size - 1);
    return arr;
}

Ed ecco la tabella riassuntiva delle prestazioni (notare che i tempi non sono in secondi ma in millisecondi):

Firefox Chrome
Bubble Sort Javascript 18,20ms 11,70ms
Bubble Sort Wasm 7ms 6,30ms

La differenza in Firefox rimane quasi immutata, la versione in WebAssembly è sempre più veloce tra 2x e 2.5x. La versione in Wasm in Chrome si è ripresa un notevole vantaggio sulla versione in Javascript, e il vantaggio si avvicina al 2x.

E' solo una supposizione, ma le differenze con l'ordinamento con Bubble Sort è alla fine molto semplice: sono solo due cicli for uno all'interno dell'altro con scambio del valore delle variabili, mentre la versione Quick Sort oltre ai cicli for fa un numero notevole di chiamate ad altre funzioni, ed è forse per questo motivo che la maggiore agilità di Wasm ha permesso di riprendere vantaggio nella versione per Chrome. Ma è solo una supposizione.

La compilazione del codice scritto in C++ è stato fatto con l'ottimizzazione massima: -O3. Se rimuovessi questa opzione che prestazioni avrei? Eccole:

Firefox Chrome
Bubble Sort Wasm 33ms 46ms

Oggetti C++ in Javascript

Inizio a scrivere dei problemi che personalmente ho trovato. Innanzitutto il passaggio di Array da Javascript a C++ (e viceversa) è da incubo. Incubo perché di base non è possibile passare facilmente questo tipo di oggetto. Esempio banale:

const arr = [1, 2, 3];
Module.ReadThisArr(arr);

E nella versione in C++:

void ReadThisArr(int *arr) {...}

La compilazione andrà a buon fine, con una piccola modifica all'esportazione del metodo, ma al momento dell'utilizzo si avranno errori:

Il modo più semplice per risolvere è utilizzando l'oggetto val. La funzione in C++ scritta in questo modo funzionerebbe correttamente:

void ReadThisArr(emscripten::val const & arr) {
    std::vector<int> data = emscripten::convertJSArrayToNumberVector<int>(arr);
    ...
}

La prima riga di codice nella funzione converte l'oggetto generico val in un vector di interi. Di contro questo trucco ha solo la lentezza nel processo di conversione.

La soluzione alternativa è creare un oggetto in C++ di tipo Vector da utilizzare poi in Javascript:

emscripten::register_vector<std::string>("VectorString");

Ora nel codice Javascript posso utilizzare questo oggetto proprio come fosse un vector in C++:

const myclass = new Module.VectorString();
myclass.push_back("stringa 1");
myclass.push_back("stringa 2");
myclass.push_back("stringa 3");

Nella funzione in C++ sarà sufficiente utilizzare l'oggetto di tipo std::vector<std::string> per ricevere correttamente il contenuto di quella collection.

Debug Wasm

Scrivere codice di una certa complessità senza Debug è follia. E nella scrittura di codice client side per il browser sono questi essenzialmente i punti che un buon Debugger dovrebbe permettere:

  • Breakpoint
  • Variable inspection
  • DOM change detection
  • Control Flow
  • Interactive Console

E il compilatore Emcripten permette il Debug? Dei punti qui sopra sono solo disponibili il Control Flow e i Breakpoint. Ecco una piccola dimostrazione. Innanzitutto si deve aggiungere dei nuovi parametri al compilatore:

docker run `
  --rm `
  -v ${PWD}:/src `
  -e EMCC_DEBUG=1 `
  -e EMCC_AUTODEBUG=1 `
  emscripten/emsdk:3.1.17 `
  emcc main.cpp -o main.js --std=c++20 --bind  -s FETCH=1 -gsource-map

Ora aprendo il browser il Web Developer Tools, si potrà trovare anche il file main.cpp:

Ed è possibile aggiungere Breakpoint che effettivamente bloccheranno il codice al momento dell'esecuzione, e sarà poi possibile eseguire il Debug passo passo, ma non è possibile controllare il contenuto delle variabili né tanto meno, modificarne il valore, o utilizzare la Console. Ecco un esempio:

Il Debug si è correttamente fermato ma non sarà possibile interagire con gli oggetti presenti nel codice. Nel pannello di destra è presente la lista delle variabili locali, e tra queste è possibile trovare quelle utilizzate nel codice, ma solo trovare la variabile utilizzata ad una di queste $var1...$var31 minerebbe la pazienza di chiunque.

Sempre meglio di niente, ma non è sufficiente.

Conclusioni

Il bello dovrebbe cominciare ora parlando di argomenti più complessi come l'utilizzo di WebGL. Ma per le mie conoscenze in merito è meglio che mi fermo qui - a mia difesa questi argomenti li ho solo trattati per curiosità e non ho approfondito visto che il mio studio sul WebAssembly in C++ era scaturita da altre motivazione, come scritto nel primo post, e poi mostrare il classico esempio di una schermata con figure geometriche di base colorate. In futuro? Forse.

Tiro alcune conclusioni strettamente personali: qual è il reale vantaggio del WebAssembly? Le prestazioni. Qualcuno aggiunge anche la tipizzazioni data dal linguaggio come il C/C++, ma questa la trovo banale come motivazione anche perché TypeScript risolve questo problema nello sviluppo tradizionale con Javascript. Di conseguenza, nella decisione di usare il WebAssembly o il classico Javascript per la creazione di una application che deve girare nel browser, qual è la molla che fa decidere di usare il più impegnativo WebAssembly? Si ritorna alla stessa risposta data alla prima domanda: le prestazioni. Per applicazioni veramente spinte che fanno uso di grafica 2D/3D diventa una scelta quasi obbligata, ma per la classica applicazione SPA con Form, inserimenti e visualizzazione di dati, ad oggi, è quanto mai sprecato l'uso del WebAssembly con Emscripten.

A questo link il codice sorgente sia del post precedente che quello del codice mostrato in questo post.

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