Optimierung von ES2015-Proxys in V8
Proxys sind seit ES2015 ein integraler Bestandteil von JavaScript. Sie ermöglichen das Abfangen grundlegender Operationen an Objekten und die Anpassung ihres Verhaltens. Proxys sind ein Kernbestandteil von Projekten wie jsdom und der Comlink RPC-Bibliothek. Kürzlich haben wir viel Aufwand in die Verbesserung der Leistung von Proxys in V8 investiert. Dieser Artikel beleuchtet allgemeine Muster zur Leistungsverbesserung in V8 und speziell für Proxys.
Proxys sind „Objekte, die verwendet werden, um benutzerdefiniertes Verhalten für grundlegende Operationen (z. B. Eigenschaftsabfrage, Zuordnung, Aufzählung, Funktionsaufruf usw.) zu definieren“ (Definition von MDN). Weitere Informationen finden Sie in der vollständigen Spezifikation. Zum Beispiel fügt der folgende Codeausschnitt jeder Eigenschaftsabfrage eines Objekts eine Protokollierung hinzu:
const target = {};
const callTracer = new Proxy(target, {
get: (target, name, receiver) => {
console.log(`get wurde aufgerufen für: ${name}`);
return target[name];
}
});
callTracer.property = 'value';
console.log(callTracer.property);
// get wurde aufgerufen für: property
// value
Konstruktion von Proxys
Das erste Feature, auf das wir uns konzentrieren, ist die Konstruktion von Proxys. Unsere ursprüngliche C++-Implementierung folgte hier der ECMAScript-Spezifikation Schritt für Schritt, was zu mindestens 4 Wechseln zwischen den C++- und JS-Runtimes führte, wie in der folgenden Abbildung gezeigt. Wir wollten diese Implementierung in den plattformunabhängigen CodeStubAssembler (CSA) portieren, der in der JS-Laufzeit und nicht in der C++-Laufzeit ausgeführt wird. Dieses Portieren minimiert die Anzahl der Wechsel zwischen den Sprachruntimes. CEntryStub
und JSEntryStub
repräsentieren die Runtimes in der nachstehenden Abbildung. Die gestrichelten Linien stellen die Grenzen zwischen den JS- und C++-Runtimes dar. Glücklicherweise waren viele Hilfsprädikate bereits im Assembler implementiert, was die erste Version prägnant und lesbar machte.
Die folgende Abbildung zeigt den Ablauf der Ausführung, wenn ein Proxy mit einer beliebigen Proxy-Falle (in diesem Beispiel apply
, die aufgerufen wird, wenn der Proxy als Funktion verwendet wird) verwendet wird, erzeugt durch den folgenden Beispielcode:
function foo(…) { … }
const g = new Proxy({ … }, {
apply: foo,
});
g(1, 2);
Nach dem Portieren der Fallen-Ausführung zu CSA erfolgt die gesamte Ausführung in der JS-Laufzeit, wodurch die Anzahl der Wechsel zwischen den Sprachen von 4 auf 0 reduziert wird.
Diese Änderung führte zu den folgenden Leistungsverbesserungen::
Unser JS-Leistungsscore zeigt eine Verbesserung zwischen 49% und 74%. Dieser Score misst grob, wie oft der gegebene Microbenchmark in 1000ms ausgeführt werden kann. Für einige Tests wird der Code mehrmals ausgeführt, um aufgrund der Timerauflösung eine hinreichend genaue Messung zu erhalten. Der Code für alle folgenden Benchmarks befindet sich in unserem js-perf-test Verzeichnis.
Call- und Construct-Fallen
Der nächste Abschnitt zeigt die Ergebnisse der Optimierung von Call- und Construct-Fallen (alias "apply"
" und "construct"
).
Die Leistungsverbesserungen beim Aufrufen von Proxys sind signifikant — bis zu 500% schneller! Dennoch ist die Verbesserung beim Erstellen von Proxys recht bescheiden, insbesondere in Fällen, in denen keine tatsächliche Falle definiert ist — nur etwa 25% Gewinn. Wir haben dies untersucht, indem wir den folgenden Befehl mit der d8
-Shell ausgeführt haben:
$ out/x64.release/d8 --runtime-call-stats test.js
> run: 120.104000
Runtime Function/C++ Builtin Time Count
========================================================================================
NewObject 59.16ms 48.47% 100000 24.94%
JS_Ausführung 23.83ms 19.53% 1 0.00%
Neuübersynch_kompilieren 11.68ms 9.57% 20 0.00%
AccessorNameGetterCallback 10.86ms 8.90% 100000 24.94%
AccessorNameGetterCallback_Funktionsprototyp 5.79ms 4.74% 100000 24.94%
Karte_SetPrototype 4.46ms 3.65% 100203 25.00%
… AUSZUG …
Die Quelle von test.js
lautet:
function MeineKlasse() {}
MeineKlasse.prototype = {};
const P = new Proxy(MeineKlasse, {});
function ausführen() {
return new P();
}
const N = 1e5;
console.time('ausführen');
for (let i = 0; i < N; ++i) {
ausführen();
}
console.timeEnd('ausführen');
Es stellte sich heraus, dass die meiste Zeit in NeuesObjekt
sowie in den Funktionen, die dieses aufruft, verbracht wird, daher begannen wir zu planen, wie wir dies in zukünftigen Versionen beschleunigen können.
Get-Falle
Der nächste Abschnitt beschreibt, wie wir die anderen häufigsten Operationen — das Abrufen und Setzen von Eigenschaften über Proxys — optimiert haben. Es stellte sich heraus, dass die get
-Falle komplizierter ist als die vorhergehenden Fälle, was auf das spezifische Verhalten von V8s Inline-Cache zurückzuführen ist. Für eine detaillierte Erklärung von Inline-Caches können Sie sich diesem Vortrag ansehen.
Schließlich gelang uns ein funktionierender Port zu CSA mit den folgenden Ergebnissen:
Nach der Einführung der Änderung stellten wir fest, dass die Größe der Android-.apk
für Chrome um ~160 KB gewachsen war, was mehr ist als erwartet für eine Hilfsfunktion von etwa 20 Zeilen. Aber zum Glück verfolgen wir solche Statistiken. Es stellte sich heraus, dass diese Funktion zweimal von einer anderen Funktion aufgerufen wurde, die dreimal aufgerufen wird, von einer weiteren, die viermal aufgerufen wird. Die Ursache des Problems war das aggressive Inlining. Schließlich lösten wir das Problem, indem wir die Inline-Funktion in einen separaten Code-Stub verwandelten, wodurch wertvolle KB eingespart wurden — die Endversion hatte nur eine Zunahme von ~19 KB in der .apk
-Größe.
Has-Falle
Der nächste Abschnitt zeigt die Ergebnisse der Optimierung der has
-Falle. Obwohl wir zunächst dachten, dass es einfacher wäre (und den meisten Code der get
-Falle wiederverwenden würde), hatte sie ihre eigenen Besonderheiten. Ein besonders schwer nachvollziehbares Problem war das Durchlaufen der Prototypkette beim Aufruf des in
-Operators. Die erzielten Verbesserungen variieren zwischen 71 % und 428 %. Wiederum sind die Gewinne deutlicher in Fällen, in denen die Falle eingesetzt wird.
Set-Falle
Der nächste Abschnitt behandelt die Portierung der set
-Falle. Dieses Mal mussten wir zwischen benannten und indizierten Eigenschaften (Elementen) unterscheiden. Diese beiden Haupttypen sind kein Bestandteil der JS-Sprache, sind jedoch entscheidend für die effiziente Eigenschaftsspeicherung von V8. Die erste Implementierung wich beim Umgang mit Elementen immer noch zur Laufzeit aus, was dazu führt, dass Sprachgrenzen überschritten werden. Dennoch erreichten wir Verbesserungen zwischen 27 % und 438 % in den Fällen, in denen die Falle gesetzt ist, auf Kosten eines bis zu 23 % Rückgangs, wenn sie nicht gesetzt ist. Diese Leistungsverschlechterung ist auf den Overhead zusätzlicher Prüfungen zur Unterscheidung zwischen indizierten und benannten Eigenschaften zurückzuführen. Für indizierte Eigenschaften gibt es noch keine Verbesserung. Hier sind die vollständigen Ergebnisse:
Verwendung in der realen Welt
Ergebnisse aus jsdom-proxy-benchmark
Das jsdom-proxy-benchmark-Projekt kompiliert die ECMAScript-Spezifikation mit dem Ecmarkup-Tool. Ab v11.2.0 verwendet das jsdom-Projekt (das Ecmarkup zugrunde liegt) Proxies, um die gemeinsamen Datenstrukturen NodeList
und HTMLCollection
zu implementieren. Wir nutzten diesen Benchmark, um einen Überblick über eine realistischere Nutzung im Vergleich zu den synthetischen Mikro-Benchmarks zu erhalten, und erzielten die folgenden Ergebnisse, Durchschnitt von 100 Durchläufen:
- Node v8.4.0 (ohne Proxy-Optimierungen): 14277 ± 159 ms
- Node v9.0.0-v8-canary-20170924 (mit nur der Hälfte der portierten Fallen): 11789 ± 308 ms
- Geschwindigkeitsgewinn von rund 2.4 Sekunden, was etwa ~17 % besser ist
- Umwandlung von
NamedNodeMap
zur Nutzung vonProxy
erhöhte die Verarbeitungszeit um- 1.9 s auf V8 6.0 (Node v8.4.0)
- 0.5 s auf V8 6.3 (Node v9.0.0-v8-canary-20170910)
Hinweis: Diese Ergebnisse wurden von Timothy Gu bereitgestellt. Danke!
Ergebnisse von Chai.js
Chai.js ist eine beliebte Assertion-Bibliothek, die intensiv Gebrauch von Proxies macht. Wir haben eine Art realitätsnahen Benchmark erstellt, indem wir die Tests mit verschiedenen Versionen von V8 durchgeführt haben – eine Verbesserung von ungefähr 1s aus mehr als 4s, Durchschnitt von 100 Durchläufen:
- Node v8.4.0 (ohne Proxy-Optimierungen): 4,2863 ± 0,14 s
- Node v9.0.0-v8-canary-20170924 (mit nur der Hälfte der portierten Traps): 3,1809 ± 0,17 s
Optimierungsansatz
Wir gehen Leistungsprobleme oft mit einem generischen Optimierungsschema an. Der Hauptansatz, dem wir bei dieser speziellen Arbeit gefolgt sind, umfasst die folgenden Schritte:
- Implementierung von Leistungstests für das spezifische Teilfeature
- Hinzufügen weiterer Tests für die Spezifikationskonformität (oder sie von Grund auf neu schreiben)
- Untersuchung der ursprünglichen C++-Implementierung
- Portierung des Teilfeatures auf den plattformunabhängigen CodeStubAssembler
- Weitere Optimierung des Codes durch manuell erstellte TurboFan-Implementierung
- Messung der Leistungsverbesserung.
Dieser Ansatz kann auf jede allgemeine Optimierungsaufgabe angewendet werden, die Sie haben.