Wie haben wir WebAssembly in Yandex.Maps implementiert und warum haben wir JavaScript verlassen?

Mein Name ist Valery Shavel, ich bin vom Entwicklungsteam der Yandex.Maps-Vektor-Engine. Kürzlich haben wir die WebAssembly-Technologie in die Engine implementiert. Im Folgenden erkläre ich Ihnen, warum wir es ausgewählt haben, welche Ergebnisse wir erzielt haben und wie Sie diese Technologie in Ihrem Projekt einsetzen können.



In Yandex.Maps besteht eine Vektorkarte aus Teilen, die als Kacheln bezeichnet werden. Tatsächlich ist die Kachel der indizierte Bereich der Karte. Die Karte ist ein Vektor, daher enthält jede Kachel viele geometrische Grundelemente. Kacheln werden vom Server zum Client verschlüsselt, und vor der Anzeige müssen alle Grundelemente verarbeitet werden. Manchmal dauert es sehr lange. Eine Kachel kann mehr als 2000 Polylinien und Polygone enthalten.



Bei der Verarbeitung von Grundelementen ist die Leistung am wichtigsten. Wenn die Kachel nicht schnell genug vorbereitet wird, sieht der Benutzer sie zu spät und die nächsten Kacheln werden in der Warteschlange verzögert. Um die Verarbeitung zu beschleunigen, haben wir uns für die relativ neue WebAssembly (Wasm) -Technologie entschieden.

Verwenden von WebAssembly in Maps


Jetzt findet der Großteil der Verarbeitung von Primitiven im Hintergrundthread (Web Worker) statt, der ein separates Leben führt. Dies geschieht, um den Haupt-Thread so weit wie möglich zu entladen. Wenn der Code zum Anzeigen der Karte auf der Service-Seite eingebettet ist, die selbst eine erhebliche Belastung verursachen kann, treten daher weniger Bremsen auf. Der Nachteil ist, dass Sie das Messaging zwischen dem Hauptthread und dem Web Worker ordnungsgemäß konfigurieren müssen.

Der Teil der Verarbeitung, der im Hintergrundthread stattfindet, besteht im Wesentlichen aus zwei Schritten:

  1. Das vom Server kommende Protobuf-Format wird dekodiert .
  2. Geometrien werden generiert und in Puffer geschrieben.

Im zweiten Schritt werden Vertex- und Indexpuffer für WebGL gebildet . Diese Puffer werden beim Rendern wie folgt verwendet. Ein Scheitelpunktpuffer enthält für jeden Scheitelpunkt seine Parameter, die zur Bestimmung seiner Position auf dem Bildschirm zu einem bestimmten Zeitpunkt erforderlich sind. Ein Indexpuffer besteht aus Dreifachen von Indizes. Jedes Tripel bedeutet, dass ein Dreieck mit Eckpunkten aus dem Eckpunktpuffer an den angegebenen Indizes auf dem Bildschirm angezeigt werden soll. Daher muss das Grundelement in Dreiecke unterteilt werden, was auch eine zeitaufwändige Aufgabe sein kann:



Offensichtlich gibt es im zweiten Schritt viele Gedächtnismanipulationen und mathematische Berechnungen, da Sie für das korrekte Rendern von Grundelementen viele Informationen zu jedem Scheitelpunkt des Grundelements benötigen:



Wir waren mit der Leistung unseres JavaScript-Codes nicht zufrieden. Zu dieser Zeit begann jeder über WebAssembly zu schreiben, die Technologie wurde ständig weiterentwickelt und verbessert. Nachdem wir die Forschungsergebnisse gelesen hatten, schlugen wir vor, dass Wasm unsere Operationen beschleunigen könnte. Obwohl wir uns dessen nicht ganz sicher waren: Es stellte sich als schwierig heraus, Daten zur Verwendung von Wasm in einem so großen Projekt zu finden.

Wasm ist auch etwas schlechter als TypeScript:

  1. Wir müssen ein spezielles Modul mit den von uns benötigten Funktionen initialisieren. Dies kann zu einer Verzögerung führen, bevor diese Funktionalität funktioniert.
  2. Die Größe des Quellcodes beim Kompilieren von Wasm ist viel größer als in TS.
  3. Entwickler müssen eine alternative Version der Codeausführung unterstützen, die auch für das Frontend in einer atypischen Sprache geschrieben ist.

Trotzdem riskierten wir, einen Teil unseres Codes mit Wasm neu zu schreiben.

Allgemeine Informationen zu WebAssembly




Wasm ist ein Binärformat. Sie können verschiedene Sprachen darin kompilieren und den Code dann in einem Browser ausführen. Oft ist ein solcher vorkompilierter Code schneller als klassisches JavaScript. Der Code im WebAssembly-Format hat keinen Zugriff auf die DOM-Elemente der Seite und wird in der Regel zum Ausführen zeitaufwendiger Rechenaufgaben auf dem Client verwendet.

Wir haben C ++ als kompilierte Sprache gewählt, da es sehr praktisch und schnell ist.

Um C ++ in WebAssembly zu kompilieren, haben wir emscripten verwendet . Nach der Installation und dem Hinzufügen zu einem C ++ - Projekt müssen Sie die Hauptprojektdatei auf eine bestimmte Weise schreiben, um das Modul zu erhalten. Zum Beispiel könnte es so aussehen:

#include <emscripten/bind.h> #include <emscripten.h> #include <math.h> struct Point { double x; double y; }; double sqr(double x) { return x * x; } EMSCRIPTEN_BINDINGS(my_value_example) { emscripten::value_object<Point>("Point") .field("x", &Point::x) .field("y", &Point::y) ; emscripten::register_vector<Point>("vector<Point>"); emscripten::function("distance", emscripten::optional_override( [](Point point1, Point point2) { return sqrt(sqr(point1.x - point2.x) + sqr(point1.y - point2.y)) ; })); } 

Als Nächstes beschreibe ich, wie Sie diesen Code in Ihrem TypeScript-Projekt verwenden können.

Im Code definieren wir die Point-Struktur und ordnen sie der Point-Schnittstelle in TypeScript zu, in der es zwei Felder gibt - x und y, die den Feldern der Struktur entsprechen.

Wenn wir den Standardvektorcontainer von C ++ nach TypeScript zurückgeben möchten, müssen wir ihn für den Point-Typ registrieren. Dann wird in TypeScript die Schnittstelle mit den erforderlichen Funktionen entsprechen.

Und schließlich zeigt der Code, wie Sie Ihre Funktion registrieren, um sie von TypeScript mit dem entsprechenden Namen aufzurufen.

Kompilieren Sie die Datei mit emscripten und fügen Sie das resultierende Modul Ihrem TypeScript-Projekt hinzu. Jetzt können wir eine generische d.ts-Datei für ein beliebiges emscripten-Modul schreiben, in der nützliche Funktionen und Typen vordefiniert sind:

 declare module "emscripten_module" { interface EmscriptenModule { readonly wasmMemory: WebAssembly.Memory; readonly HEAPU8: Uint8Array; readonly HEAPF64: Float64Array; locateFile: (path: string) => string; onRuntimeInitialized: () => void; _malloc: (size: size_t) => uintptr_t; _free: (addr: size_t) => uintptr_t; } export default EmscriptenModule; export type uintptr_t = number; export type size_t = number; } 

Und wir können die Datei d.ts für unser Modul schreiben:

 declare module "emscripten_point" { import EmscriptenModule, {uintptr_t, size_t} from 'emscripten_module'; interface NativeObject { delete: () => void; } interface Vector<T> extends NativeObject { get(index: number): T; size(): number; } interface Point { readonly x: number; readonly y: number; } interface PointModule extends EmscriptenModule { distance: (point1: Point, point2: Point) => number; } type PointModuleUninitialized = Partial<PointModule>; export default function createModuleApi(Module: Partial<PointModule>): PointModule; } 

Jetzt können wir eine Funktion schreiben, die Promise für die Modulinitialisierung erstellt, und sie verwenden:

 import EmscriptenModule from 'emscripten_module'; import createPointModuleApi, {PointModule} from 'emscripten_point'; import * as pointModule from 'emscripten_point.wasm'; /** * Promisifies initialization of emscripten module. * * @param moduleUrl URL to wasm file, it could be encoded data URL. * @param moduleInitializer Escripten module factory, * see https://emscripten.org/docs/compiling/WebAssembly.html#compiler-output. */ export default function initEmscriptenModule<ModuleT extends EmscriptenModule>( moduleUrl: string, moduleInitializer: (module: Partial<EmscriptenModule>) => ModuleT ): Promise<ModuleT> { return new Promise((resolve) => { const module = moduleInitializer({ locateFile: () => moduleUrl, onRuntimeInitialized: function (): void { // module itself is thenable, to prevent infinite promise resolution delete (<any>module).then; resolve(module); } }); }); } const initialization = initEmscriptenModule( 'data:application/wasm;base64,' + pointModule, createPointModuleApi ); 

Jetzt für dieses Versprechen bekommen wir unser Modul zusammen mit der Distanzfunktion.

Leider können Sie den Wasm-Code nicht zeilenweise im Browser debuggen. Daher ist es notwendig, Tests zu schreiben und Code darauf wie in normalem C ++ auszuführen, damit Sie die Möglichkeit zum bequemen Debuggen haben. Trotzdem haben Sie auch im Browser Zugriff auf den Standard-Cout-Stream, der alles an die Browserkonsole ausgibt.

Ein Projektbeispiel aus dem Artikel ist über diesen Link verfügbar, wo Sie die Einstellungen für webpack.config und CMakeLists sehen können.

Ergebnisse


Also haben wir einen Teil unseres Codes neu geschrieben und ein Experiment gestartet, um das Parsen von Polygonen und Polygonen zu berücksichtigen. Das Diagramm zeigt die mittleren Ergebnisse für eine Kachel für Wasm und JavaScript:



Als Ergebnis erhalten wir für jede Metrik solche relativen Koeffizienten:



Wie Sie an der reinen primitiven Parsing-Zeit und der Dekodierungszeit für Kacheln sehen können, ist Wasm mehr als viermal schneller. Wenn Sie sich die gesamte Parsing-Zeit ansehen, ist der Unterschied ebenfalls signifikant, aber immer noch etwas geringer. Dies ist auf die Kosten zurückzuführen, die für die Übermittlung von Daten an Wasm und die Erfassung des Ergebnisses anfallen. Es ist auch erwähnenswert, dass in den ersten Kacheln der Gesamtgewinn sehr hoch ist (in den ersten zehn - mehr als fünf Mal). Dann nimmt der relative Koeffizient jedoch auf etwa drei ab.

Auf diese Weise konnte die Verarbeitungszeit einer Kachel im Hintergrundfaden um 20–25% reduziert werden. Natürlich ist dieser Unterschied nicht so groß wie der der vorherigen, aber Sie müssen verstehen, dass das Parsen von unterbrochenen Linien und Polygonen weit von der gesamten Kachelverarbeitung entfernt ist.

Wenn wir über die Notwendigkeit sprechen, das Modul zu initialisieren, hatte ungefähr die Hälfte der Benutzer eine Verzögerung, bevor sie die erste Kachel analysierten. Die mediane Verzögerung beträgt 188 ms. Die Verzögerung tritt nur vor dem ersten Plättchen auf, und der Gewinn beim Parsen ist konstant, sodass Sie am Anfang eine kleine Pause in Kauf nehmen können, die kein ernstes Problem darstellt.

Eine andere negative Seite ist die Größe der Quellcodedatei. Gzip-komprimierter verkleinerter Code für die gesamte Vektorkarten-Engine ohne Wasm - 85 KB, mit Wasm - 191 KB. Gleichzeitig wird in Wasm nur das Parsen von unterbrochenen Linien und Rechtecken implementiert, und nicht alle Grundelemente, die sich in einer Kachel befinden können. Außerdem musste ich zum Dekodieren von protobuf eine Bibliotheksimplementierung in reinem C wählen, bei einer C ++ - Implementierung war die Größe sogar noch größer. Dieser Unterschied kann durch die Verwendung des Compiler-Flags -Oz anstelle von -O3 beim Kompilieren von C ++ etwas verringert werden, ist aber immer noch von Bedeutung. Darüber hinaus verlieren wir durch einen solchen Austausch an Produktivität.

Die Größe der Quelle hatte jedoch keinen wesentlichen Einfluss auf die Initialisierungsgeschwindigkeit der Karte. Wasm ist nur auf langsamen Geräten schlechter und die Differenz beträgt weniger als 2%. Die anfänglich sichtbaren Vektorkacheln in der Implementierung mit Wasm wurden den Benutzern jedoch etwas schneller als mit der JS-Implementierung angezeigt. Dies ist auf den höheren Gewinn bei den ersten bearbeiteten Kacheln zurückzuführen, während JS noch nicht optimiert ist.

Daher ist Wasm jetzt eine gute Option, wenn Sie mit der Leistung von JavaScript-Code nicht vertraut sind. Gleichzeitig können Sie weniger Leistungsgewinn erzielen als wir, oder Sie können ihn überhaupt nicht erzielen. Dies liegt an der Tatsache, dass JavaScript selbst manchmal recht schnell funktioniert und Sie in Wasm Daten übertragen und das Ergebnis sammeln müssen.

Auf unseren Karten wird jetzt regelmäßig JavaScript ausgeführt. Dies liegt an der Tatsache, dass der Gewinn beim Parsen vor dem allgemeinen Hintergrund nicht so groß ist, und an der Tatsache, dass nur einige Arten von Grundelementen in Wasm implementiert sind. Wenn sich dies ändert, verwenden wir möglicherweise Wasm. Ein weiteres schlagkräftiges Argument dagegen ist die Komplexität des Assemblierens und Debuggens: Die Unterstützung eines Projekts in zwei Sprachen ist nur dann sinnvoll, wenn sich der Leistungszuwachs lohnt.

Source: https://habr.com/ru/post/de475382/


All Articles