Home
» Modul-Aufteilung
wasm-split und die Emscripten-Integration SPLIT_MODULE befinden sich beide in aktiver Entwicklung und können sich häufig ändern und neue Funktionen erhalten. Diese Seite wird mit den neuesten Änderungen auf dem Laufenden gehalten.
Große Codebasen enthalten oft viel Code, der in der Praxis sehr selten oder nie früh im Lebenszyklus der Anwendung verwendet wird. Das Laden dieses ungenutzten Codes kann den Anwendungsstart merklich verzögern, daher wäre es gut, das Laden dieses Codes auf einen Zeitpunkt nach dem Start der Anwendung zu verschieben. Eine hervorragende Lösung hierfür ist die Verwendung von Dynamic Linking, aber das erfordert eine Umstrukturierung einer Anwendung in gemeinsam genutzte Bibliotheken und bringt auch einen gewissen Performance-Overhead mit sich, so dass es nicht immer praktikabel ist. Modul-Aufteilung ist ein weiterer Ansatz, bei dem ein Modul nach dem normalen Bauen in separate Teile, das primäre und das sekundäre Modul, aufgeteilt wird. Das primäre Modul wird zuerst geladen und enthält den für den Start der Anwendung notwendigen Code, während das sekundäre Modul Code enthält, der später oder gar nicht benötigt wird. Das sekundäre Modul wird automatisch bei Bedarf geladen.
wasm-split ist ein Binaryen-Tool, das die Modul-Aufteilung durchführt. Nach dem Ausführen von wasm-split hat das primäre Modul die gleichen Importe und Exporte wie das Originalmodul und soll ein Drop-in-Ersatz dafür sein. Es importiert jedoch auch eine Platzhalterfunktion für jede sekundäre Funktion, die in das sekundäre Modul aufgeteilt wurde. Bevor das sekundäre Modul geladen wird, rufen Aufrufe von sekundären Funktionen stattdessen die entsprechende Platzhalterfunktion auf. Die Platzhalterfunktionen sind für das Laden und Instanziieren des sekundären Moduls verantwortlich, das beim Instanziieren automatisch alle Platzhalterfunktionen durch die ursprünglichen sekundären Funktionen ersetzt. Nach dem Laden des sekundären Moduls ist die Platzhalterfunktion, die es geladen hat, auch dafür verantwortlich, ihre entsprechende neu geladene sekundäre Funktion aufzurufen und das Ergebnis an ihren Aufrufer zurückzugeben. Das Laden des sekundären Moduls ist daher für das primäre Modul völlig transparent; es sieht einfach so aus, als ob ein Funktionsaufruf lange gedauert hat, bis er zurückgekehrt ist.
Derzeit umfasst der einzige Workflow zum Aufteilen von Modulen die Instrumentierung des Originalmoduls, um ein Profil der ausgeführten Funktionen zu sammeln, das Ausführen des instrumentierten Moduls mit einer Reihe interessanter Workloads und die Verwendung der resultierenden Profile, um zu bestimmen, wie das Modul aufgeteilt werden soll. wasm-split belässt jede Funktion, die während einer der profilierten Workloads ausgeführt wurde, im primären Modul und teilt alle anderen Funktionen in das sekundäre Modul auf.
Emscripten verfügt über eine Prototyp-Integration mit wasm-split, die durch die Option -sSPLIT_MODULE aktiviert wird. Diese Option emittiert das Originalmodul mit der angewendeten wasm-split-Instrumentierung, so dass es bereit ist, Profile zu sammeln. Es fügt auch die Platzhalterfunktionen, die für das Laden eines sekundären Moduls verantwortlich sind, in das emittierte JS ein. Der Entwickler ist dann dafür verantwortlich, geeignete Workloads auszuführen, die Profile zu sammeln und das wasm-split-Tool zum Durchführen der Aufteilung zu verwenden. Nachdem das Modul aufgeteilt wurde, funktioniert alles korrekt, ohne weitere Änderungen am JS, das durch die anfängliche Kompilierung erzeugt wurde.
Gehen wir ein grundlegendes Beispiel für die Verwendung von SPLIT_MODULE mit Node durch. Später im Abschnitt „Ausführen im Web“ werden wir besprechen, wie das Beispiel auch für die Ausführung im Web angepasst werden kann.
Hier ist unser Anwendungscode
// application.c
#include <stdio.h>
#include <emscripten.h>
void foo() {
printf("foo\n");
}
void bar() {
printf("bar\n");
}
void unsupported(int i) {
printf("%d is not supported!\n", i);
}
EM_JS(int, get_number, (), {
if (typeof prompt === 'undefined') {
prompt = require('prompt-sync')();
}
return parseInt(prompt('Give me 0 or 1: '));
});
int main() {
int i = get_number();
if (i == 0) {
foo();
} else if (i == 1) {
bar();
} else {
unsupported(i);
}
}
Diese Anwendung fordert den Benutzer zur Eingabe auf und führt je nach Eingabe des Benutzers verschiedene Funktionen aus. Sie verwendet das npm-Modul prompt-sync, um das Eingabeverhalten zwischen Node und dem Web portabel zu machen. Wir werden sehen, dass die Eingabe, die wir während der Profilerstellung bereitstellen, bestimmt, wie unsere Funktionen zwischen dem primären und dem sekundären Modul aufgeteilt werden.
Wir können unsere Anwendung mit -sSPLIT_MODULE kompilieren.
$ emcc application.c -o application.js -sSPLIT_MODULE
Zusätzlich zu den typischen Dateien application.wasm und application.js wird auch eine Datei application.wasm.orig erzeugt. application.wasm.orig ist das ursprüngliche, unmodifizierte Modul, das ein normaler Emscripten-Build erzeugen würde, während application.wasm von wasm-split instrumentiert wurde, um Profile zu sammeln.
Das instrumentierte Modul hat eine zusätzliche exportierte Funktion, __write_profile, die als Argumente einen Zeiger und eine Länge für einen In-Memory-Puffer nimmt, in den sie das Profil schreibt. __write_profile gibt die Länge des Profils zurück und schreibt die Daten nur, wenn der bereitgestellte Puffer groß genug ist. __write_profile kann extern von JS oder intern, von der Anwendung selbst, aufgerufen werden. Der Einfachheit halber rufen wir es hier einfach am Ende unserer Hauptfunktion auf, aber beachten Sie, dass dies bedeutet, dass alle nach main aufgerufenen Funktionen, wie z. B. Destruktoren für globale Objekte, nicht im Profil enthalten sind.
Hier ist die Funktion zum Schreiben des Profils und unsere neue Hauptfunktion
EM_JS(void, write_profile, (), {
var __write_profile = wasmExports.__write_profile;
if (!__write_profile) {
return;
}
// Get the size of the profile and allocate a buffer for it.
var len = __write_profile(0, 0);
var ptr = _malloc(len);
// Write the profile data to the buffer.
__write_profile(ptr, len);
// Write the profile file.
var profile_data = HEAPU8.subarray(ptr, ptr + len);
const fs = require("fs");
fs.writeFileSync('profile.data', profile_data);
// Free the buffer.
_free(ptr);
});
int main() {
int i = get_number();
if (i == 0) {
foo();
} else if (i == 1) {
bar();
} else {
unsupported(i);
}
write_profile();
}
Beachten Sie, dass wir nur versuchen, das Profil zu schreiben, wenn der Export __write_profile existiert. Dies ist wichtig, da nur das instrumentierte, ungeteilte Modul __write_profile exportiert. Die geteilten Module werden die Profiling-Instrumentierung oder diesen Export nicht enthalten.
Unsere neue Funktion write_profile hängt davon ab, dass malloc und free für JS verfügbar sind, daher müssen wir sie explizit in der Befehlszeile exportieren.
$ emcc application.c -o application.js -sSPLIT_MODULE -sEXPORTED_FUNCTIONS=_malloc,_free,_main
Nun können wir unsere Anwendung ausführen, die eine Datei profile.data erzeugt. Der nächste Schritt ist die Verwendung von wasm-split und des Profils, um das ursprüngliche Modul application.wasm aufzuteilen.
$ wasm-split --enable-mutable-globals --export-prefix=% application.wasm.orig -o1 application.wasm -o2 application.deferred.wasm --profile=profile.data
Lassen Sie uns die Bedeutung all dieser Optionen aufschlüsseln.
--enable-mutable-globalsDiese Option aktiviert die Target-Funktion „mutable-global“, die das Importieren und Exportieren von veränderlichen Wasm-Globalvariablen (im Gegensatz zu C/C++-Globalvariablen) erlaubt. wasm-split muss veränderliche Globalvariablen zwischen dem primären und sekundären Modul teilen, daher erfordert es die Aktivierung dieser Funktion.
--export-prefix=%Dies ist ein Präfix, das allen neuen Exporten hinzugefügt wird, die wasm-split erstellt, um Modulelemente vom primären Modul an das sekundäre Modul weiterzugeben. Das Präfix kann verwendet werden, um „echte“ Exporte von solchen zu unterscheiden, die nur zur Nutzung durch das sekundäre Modul existieren. Die wasm-split-Integration von Emscripten erwartet insbesondere, dass „%“ als Präfix verwendet wird.
-o1 application.wasmSchreiben Sie das primäre Modul in application.wasm. Beachten Sie, dass dies das zuvor von Emscripten erzeugte instrumentierte Modul überschreibt, sodass die Anwendung nun die aufgeteilten Module anstelle des instrumentierten Moduls verwendet.
-o2 application.deferred.wasmSchreiben Sie das sekundäre Modul in application.deferred.wasm. Emscripten erwartet, dass der Name des sekundären Moduls derselbe ist wie der Name des primären Moduls, wobei „.wasm“ durch „.deferred.wasm“ ersetzt wird.
--profile=profile.dataWeist wasm-split an, das Profil in profile.data zur Steuerung der Aufteilung zu verwenden.
Wenn wir application.js erneut in Node ausführen, können wir sehen, dass die Anwendung genauso funktioniert wie zuvor, aber wenn wir einen anderen Codepfad als den im profilierten Workload verwendeten ausführen, gibt die Anwendung eine Konsolennachricht aus, dass eine Platzhalterfunktion aufgerufen und das verzögerte Modul geladen wird.
wasm-split unterstützt das Zusammenführen von Profilen aus mehreren Profiling-Workloads in einem einzigen Profil, um die Aufteilung zu steuern. Jede Funktion, die in einem der Workloads ausgeführt wurde, bleibt im primären Modul, und alle anderen Funktionen werden in das sekundäre Modul aufgeteilt.
Dieser Befehl führt beliebig viele Profile (hier nur profile1.data und profile2.data) zu einem einzigen Profil zusammen.
$ wasm-split --merge-profiles profile1.data profile2.data -o profile.data
Standardmäßig werden die von der wasm-split-Instrumentierung gesammelten Daten in Wasm-Globalvariablen gespeichert und sind somit Thread-Lokal. In einem Multithread-Programm ist es jedoch wichtig, Profilinformationen von allen Threads zu sammeln. Dazu können Sie wasm-split anweisen, gemeinsame Profilinformationen im gemeinsamen Speicher unter Verwendung des --in-memory wasm-split-Flags zu sammeln. Dies verwendet den Speicher ab Adresse Null, um die Profilinformationen zu speichern, daher müssen Sie auch -sGLOBAL_BASE=N an Emscripten übergeben, wobei N mindestens die Anzahl der Funktionen im Modul ist, um zu verhindern, dass das Programm diesen Speicherbereich überschreibt.
Nach der Aufteilung laden und kompilieren multithreaded-Anwendungen das Sekundärmodul derzeit separat auf jedem Thread. Das kompilierte Sekundärmodul wird nicht an jeden Thread postmessaged, so wie Emscripten das Primärmodul an die Threads postmessagt. Das ist nicht so schlimm, wie es klingt, da Downloads des Sekundärmoduls von Workern aus dem Cache bedient werden, wenn die entsprechenden Cache-Control-Header gesetzt sind, aber die Verbesserung ist ein Bereich für zukünftige Arbeiten.
Eine Komplikation, die bei der Verwendung von SPLIT_MODULE für Webanwendungen zu beachten ist, besteht darin, dass das sekundäre Modul nicht sowohl lazy als auch asynchron geladen werden kann, was bedeutet, dass es auf dem Hauptbrowser-Thread nicht lazy geladen werden kann. Der Grund ist, dass die Platzhalterfunktionen für die Funktionen im primären Modul vollständig transparent sein müssen, sodass sie erst zurückkehren können, wenn sie die korrekte sekundäre Funktion synchron geladen und aufgerufen haben.
Eine Problemumgehung für diese Einschränkung wäre, das Sekundärmodul eifrig zu laden und zu instanziieren und sicherzustellen, dass keine sekundären Funktionen aufgerufen werden können, bevor es auf dem Hauptbrowser-Thread instanziiert wurde. Dies könnte jedoch schwierig zu gewährleisten sein. Eine weitere Lösung wäre, die Asyncify-Transformation auf das primäre Modul anzuwenden, um Platzhalterfunktionen die Rückkehr zur JS-Ereignisschleife zu ermöglichen, während auf das asynchrone Laden des Sekundärmoduls gewartet wird. Dies steht auf der wasm-split-Roadmap, obwohl wir noch nicht wissen, wie groß der Größen- und Performance-Overhead dieser Lösung sein wird.
Diese Einschränkung beim verzögerten Laden bedeutet, dass die beste Art, Anwendungen mit SPLIT_MODULE auszuführen, in einem Worker-Thread ist, zum Beispiel mit -sPROXY_TO_PTHREAD. Im PROXY_TO_PTHREAD-Modus ist es wichtig, zusätzlich zum Anwendungshauptthread ein Profil für den Browser-Hauptthread zu sammeln, da der Browser-Hauptthread einige Funktionen ausführt, die nicht im Anwendungshauptthread ausgeführt werden, wie z.B. den Shim, der die proxied-Hauptfunktion umschließt, und die Funktionen, die an der Behandlung von Anrufen beteiligt sind, die zurück an den Browser-Hauptthread weitergeleitet werden. Im vorherigen Abschnitt erfahren Sie, wie Profile von mehreren Threads gesammelt werden.
Eine weitere kleine Komplikation besteht darin, dass die Profildaten nicht sofort aus dem Browser in eine Datei geschrieben werden können. Die Daten müssen stattdessen auf andere Weise an Entwicklermaschinen übertragen werden, z. B. durch Posten an den Dev-Server oder Kopieren einer Base64-Kodierung davon aus der Konsole.
Hier ist der Code, der die Base64-Lösung implementiert.
var profile_data = HEAPU8.subarray(ptr, ptr + len);
var binary = '';
for (var i = 0; i < profile_data.length; i++) {
binary += String.fromCharCode(profile_data[i]);
}
console.log("===BEGIN===");
console.log(window.btoa(binary));
console.log("===END===");
Anschließend kann die Profil-Datei erstellt werden durch Ausführen von
$ echo [pasted base64] | base64 --decode > profile.data
oder
$ base64 --decode [base64 file] > profile.data
Die Modul-Aufteilung kann in Verbindung mit dynamischem Linking verwendet werden, aber die korrekte Zusammenarbeit der beiden Funktionen erfordert ein gewisses Eingreifen des Entwicklers. wasm-split muss oft die Tabelle erweitern, um Platz für Platzhalterfunktionen zu schaffen, aber das bedeutet, dass die instrumentierten und aufgeteilten Module unterschiedliche Tabellengrößen hätten. Normalerweise ist dies kein Problem, aber die dynamische Linking-Unterstützung von MAIN_MODULE/SIDE_MODULE erfordert derzeit, dass die Tabellengröße in das von Emscripten emittierte JS eingebettet ist, daher muss die Tabellengröße stabil sein.
Um sicherzustellen, dass die Tabellengröße zwischen dem instrumentierten Modul und den aufgeteilten Modulen dieselbe ist, verwenden Sie die Emscripten-Einstellung -sINITIAL_TABLE=N, wobei N die gewünschte Tabellengröße ist. Wenn Sie dann wasm-split zum Aufteilen verwenden, übergeben Sie --initial-table=N an wasm-split, um sicherzustellen, dass auch die aufgeteilten Module die korrekte Tabellengröße haben.
Wenn die angegebene Tabellengröße zu klein ist, erhalten Sie nach dem Aufteilen eine Fehlermeldung, wenn das primäre Modul geladen wird. Passen Sie die von Ihnen angegebene Tabellengröße an, bis sie groß genug ist. Abgesehen von der zusätzlichen Speichernutzung zur Laufzeit gibt es keinen Nachteil, eine größere als notwendige Tabellengröße anzugeben.
Die Standardlogik für das verzögerte Laden des Sekundärmoduls kann durch die Implementierung der benutzerdefinierten Hook-Funktion „loadSplitModule“ überschrieben werden. Der Hook wird von Platzhalterfunktionen aufgerufen und ist dafür verantwortlich, das [Instanz, Modul]-Paar für das Sekundärmodul zurückzugeben. Der Hook nimmt als Argumente den Namen der zu ladenden Datei (z.B. „my_program.deferred.wasm“), das Imports-Objekt, mit dem das Modul instanziiert werden soll, und die Eigenschaft, die der aufgerufenen Platzhalterfunktion entspricht. Hier ist eine Beispielimplementierung, die dasselbe tut wie die Standardimplementierung mit einigen zusätzlichen Protokollierungen.
Module["loadSplitModule"] = function(deferred, imports, prop) {
console.log('Custom handler for loading split module.');
console.log('Called with placeholder ', prop);
return instantiateSync(deferred, imports);
}
Wenn das Modul eagerly geladen wurde, könnte dieser Hook einfach das Modul instanziieren, anstatt es auch abzurufen und zu kompilieren. Wenn jedoch das eagerly geladene Modul auch eagerly instanziiert wird, werden die Platzhalterfunktionen herausgepatcht und überhaupt nicht aufgerufen, sodass dieser benutzerdefinierte Hook ebenfalls nie aufgerufen wird.
Beim eifrigen Instanziieren des Sekundärmoduls sollte das Importobjekt sein
{'primary': wasmExports}
wasm-split bietet mehrere Optionen, um das Debuggen von Split-Modulen zu erleichtern.
-vBeim Aufteilen die primären und sekundären Funktionen ausgeben. Beim Zusammenführen von Profilen Profile ausgeben, die nicht zum zusammengeführten Profil beitragen.
-gNamen in primären und sekundären Modulen beibehalten. Ohne diese Option entfernt wasm-split die Namen stattdessen.
--emit-module-namesModulnamen generieren und ausgeben, um das primäre und sekundäre Modul in Stack-Traces zu unterscheiden, auch wenn -g nicht verwendet wird.
--symbolmapSeparate Map-Dateien für das primäre und sekundäre Modul ausgeben, die Funktionsindizes Funktionsnamen zuordnen. In Kombination mit –emit-module-names können diese Maps verwendet werden, um Stack-Traces neu zu symbolisieren. Um sicherzustellen, dass die Funktionsnamen für wasm-split verfügbar sind, um sie in die Maps auszugeben, übergeben Sie –profiling-funcs an Emscripten.
--placeholdermapEine Map-Datei ausgeben, die Platzhalter-Funktionsindizes ihren entsprechenden sekundären Funktionen zuordnet. Dies kann nützlich sein, um herauszufinden, welche Funktion das Laden des sekundären Moduls verursacht hat.
Eine Liste von Änderungen und neuen Funktionen, die noch nicht in diese Dokumentation aufgenommen wurden.
Es ist geplant, eine Integration mit der Asyncify-Instrumentierung zu entwickeln, die es ermöglicht, das sekundäre Modul asynchron auf dem Hauptbrowser-Thread zu laden.