Μετάβαση στο περιεχόμενο

VM bytecode

Η προεπιλεγμένη διαδρομή εκτέλεσης είναι ένας περιπατητής δέντρου — επισκέπτεται κάθε κόμβο AST και επιστρέφει μια τιμή. Για συναρτήσεις που καλούνται εκατομμύρια φορές, το κόστος αποστολής ανά κόμβο γίνεται σημείο συμφόρησης. Το Phasis προσθέτει από πάνω έναν εικαστικό μεταγλωττιστή bytecode σε closure PHP για να μειώσει αυτό το κόστος.

Πότε ενεργοποιείται η VM

Κάθε JsFunction διαθέτει μετρητή συχνών κλήσεων. Όταν ξεπεράσει ένα όριο (προεπιλογή 100), η μηχανή επιχειρεί να μεταγλωττίσει το σώμα της συνάρτησης σε closure PHP μέσω του src/Bytecode/JsToPhp.php.

Η μεταγλώττιση επιτυγχάνει όταν το σώμα της συνάρτησης ταιριάζει σε ένα γνωστό σύνολο σχημάτων:

  • Αναθέσεις τοπικών μεταβλητών με προβλέψιμους τύπους (αριθμητικός, object, συνάρτηση, πίνακας, συμβολοσειρά).
  • Αριθμητική με αριθμούς.
  • Ανάγνωση μελών από τοπικές μεταβλητές τύπου object ΚΑΙ από this (this.v, obj.x).
  • Κλήσεις μεθόδων σε τοπικές μεταβλητές τύπου object (obj.method(args)), με προώθηση του δέκτη ως $thisValue.
  • Κλήσεις κατασκευαστή new Ctor(args) — αναβαθμίζει τον τύπο της τοπικής μεταβλητής LHS σε object και προστατεύει με Bailout την περίπτωση μη-object επιστροφής.
  • Βρόχοι for / while με απλά σχήματα συνθηκών.
  • return με υπολογίσιμη έκφραση.
  • if / else πάνω σε τυποποιημένες συνθήκες.

Η μεταγλώττιση αποτυγχάνει (και η συνάρτηση παραμένει στον περιπατητή δέντρου) όταν το σώμα περιέχει:

  • Δηλώσεις with.
  • try / catch / finally (το χειρίζεται ο περιπατητής δέντρου για σωστή σημασιολογία ολοκλήρωσης).
  • Κλήσεις eval().
  • Άμεση μετάλλαξη arguments.
  • Οτιδήποτε δεν ξέρει ακόμη να μετατρέπει ο μεταγλωττιστής.

Όταν η μεταγλώττιση πετύχει, η μηχανή αλλάζει τη διαδρομή κλήσης της συνάρτησης στο μεταγλωττισμένο closure. Οι επόμενες κλήσεις εκτελούν εγγενές PHP με το κόστος μίας κλήσης συνάρτησης, αντί για μία ανά κόμβο AST.

Διακοπές

Η γρήγορη διαδρομή μεταγλώττισης βασίζεται σε παραδοχές (οι αριθμοί παραμένουν αριθμοί, οι ιδιότητες παραμένουν στο ίδιο σχήμα). Αν κάποια παραδοχή παραβιαστεί κατά την εκτέλεση, το closure ρίχνει ένα Bailout και η εκτέλεση ξαναρχίζει στον περιπατητή δέντρου για εκείνη την κλήση.

Εδώ έχει τη μεγαλύτερη σημασία η ορθότητα της προδιαγραφής. Ο περιπατητής δέντρου πρέπει να μπορεί να επανεκτελέσει από την αρχή την κλήση που διακόπηκε, χωρίς να παρατηρήσει παρενέργειες από την αποτυχημένη γρήγορη διαδρομή. Το Phasis το διαχειρίζεται με δύο τρόπους:

  1. Καθαρά προθέματα — ο μεταγλωττιστής ελέγχει για παρενέργειες πριν από κάθε εικασία. Αν η συνάρτηση εκτελεί I/O (console.log, συναρτήσεις host) πριν από το σημείο εικασίας, η γρήγορη διαδρομή δεν το παραλείπει· δεσμεύει τις αλλαγές και συνεχίζει.
  2. Άρνηση κατά τη μεταγλώττιση — για σχήματα που θα απαιτούσαν αναίρεση παρατηρήσιμων παρενεργειών (μη αριθμητικό let r = fn() όπου το fn() μεταβάλλει την κατάσταση), ο μεταγλωττιστής αρνείται να μεταγλωττίσει. Η συνάρτηση παραμένει στον περιπατητή δέντρου.

Το χαρακτηριστικό παράδειγμα: μια μη αριθμητική ανάθεση εκτελεί το fn() στη γρήγορη διαδρομή, ρίχνει το Bailout και μετά εκτελείται ξανά στον περιπατητή δέντρου — εκτελώντας το fn() δύο φορές, με δύο σύνολα παρενεργειών. Η άρνηση κατά τη μεταγλώττιση αποτρέπει την εκτέλεση του fn() στη γρήγορη διαδρομή, ώστε ο περιπατητής δέντρου να παρατηρήσει τη μοναδική κλήση.

Αποστολή VM (το δεύτερο επίπεδο)

Ορισμένες συναρτήσεις μεταγλωττίζονται σε closures PHP· άλλες περνούν σε έναν ακόμη απλούστερο διερμηνευτή bytecode στο src/Bytecode/VM.php. Το bytecode είναι ένας επίπεδος πίνακας opcodes (LOAD_LOCAL, STORE_PROP, CALL_METHOD, …) που καταναλώνεται από αποστολή switch.

Η VM προορίζεται για συναρτήσεις που δεν αξίζει να μεταγλωττιστούν σε closure PHP, αλλά είναι αρκετά συχνές ώστε να ωφελούνται από την παράλειψη της διάσχισης AST. Οι κλήσεις μεθόδων σε ενσωματωμένα αντικείμενα (Array.prototype.map, String.prototype.split, …) ακολουθούν γρήγορες διαδρομές στη VM που παρακάμπτουν το γενικό trampoline callFunction.

Προσαρμοσμένη στοίβα κλήσεων

Τα Op::CALL, Op::CALL_METHOD και Op::NEW_CALL χρησιμοποιούν διαδρομή inline κλήσεων που διατηρεί ολόκληρη τη στοίβα κλήσεων JS μέσα στο VM::execute — χωρίς νέο πλαίσιο PHP για κάθε κλήση JS. Κατά την είσοδο, η κατάσταση του καλούντος (pc, ροή κώδικα, στοίβα τελεστέων, τοπικές μεταβλητές, env, this, σημαία strict mode) αποθηκεύεται σε μια δεξαμενή savedFrames ανά VM με στιγμιότυπα SavedFrame, και ο βρόχος αποστολής ανασυνδέει τις τοπικές μεταβλητές του με τις μεταβλητές του καλούμενου. Το Op::RET αφαιρεί το αποθηκευμένο πλαίσιο και επαναφέρει την κατάσταση. Οι εξαιρέσεις ξετυλίγονται διασχίζοντας τα αποθηκευμένα πλαίσια πριν επιστρέψουν στον περιπατητή δέντρου.

Η επιλεξιμότητα αποθηκεύεται προσωρινά στο JsFunction: vmInlineCallCache για κανονικές κλήσεις (σώμα μεταγλωττισμένο σε BC, χωρίς δέσμευση σε bind(), όχι εγγενές), vmInlineConstructCache για εκφράσεις new (επιπλέον isConstructable, όχι derived, χωρίς αρχικοποιητές πεδίων instance ή ιδιωτικών μεθόδων). Ό,τι άλλο εξακολουθεί να περνά από το κανονικό trampoline callFunction / vmNewExpression.

Το αρχιτεκτονικό πρόσθετο όφελος: το όριο στοίβας PHP δεν περιορίζει πλέον το βάθος αναδρομής JS. Μόνο το CallStack::maxDepth θέτει πλέον όριο — στα 4 096, αρκετά πάνω από κάθε θεμιτή περίπτωση test262 (οι περισσότερες χρειάζονται κάτω από 2 k πλαίσια) και αρκετά χαμηλά ώστε οι παθολογικές δοκιμές άπειρης αναδρομής να αποτυγχάνουν γρήγορα, εντός του ορίου χρόνου του runner. Η διαδρομή closure που παράγεται από transpiler (JsToPhp) έχει δικό της όριο 8 πλαισίων 192 για την εναλλακτική διαδρομή χωρίς inline κλήσεις.

Generators με στιγμιότυπο πλαισίου

Απλά σώματα generator — χωρίς yield*, χωρίς yield μέσα σε try/catch/finally και χωρίς await — μεταγλωττίζονται σε bytecode. Ο μεταγλωττιστής εκπέμπει νέο opcode Op::YIELD στο σημείο αναστολής του σώματος· το Op::YIELD καταγράφει την κατάσταση αποστολής (cf, pc+1, stack, sp, locals, env, thisValue, strict) σε αντικείμενο σωρού GeneratorSnapshot και επιστρέφει ένα sentinel YieldResult από το VM::execute. Το JsGenerator που οδηγεί την εκτέλεση αποθηκεύει το στιγμιότυπο και το επιστρέφει στο VM::execute στο επόμενο next() / throw(), αντικαθιστώντας την τιμή συνέχισης στην κορυφή της στοίβας τελεστέων.

Αυτό αντικαθιστά τη διαδρομή PHP-Fiber για το υποστηριζόμενο υποσύνολο. Ο οδηγός που βασίζεται σε Fiber (executeGeneratorBody) παραμένει για σχήματα που απορρίπτει ο μεταγλωττιστής — ανάθεση yield*, generators που κάνουν yield μέσα σε try-block, async generators και async συναρτήσεις με await. Το JsGenerator λειτουργεί σε δύο τρόπους: κάθε next() / throw() / return() ελέγχει αν το στιγμιότυπο διαθέτει snapshot ή Fiber και αποστέλλει ανάλογα.

Δεν υπάρχει ρύθμιση για βελτιστοποίηση επιδόσεων· η επιλεξιμότητα αποφασίζεται κατά τη μεταγλώττιση και αποθηκεύεται προσωρινά στο JsFunction. Το όφελος είναι μία λιγότερη εναλλαγή περιβάλλοντος PHP ανά yield στη διαδρομή snapshot· η διαδρομή Fiber παραμένει ίδια για όλα τα υπόλοιπα.

Ενσωματωμένες κρυφές μνήμες

Το Op::LOAD_MEMBER διατηρεί inline cache ανά PC, με κλειδί την ταυτότητα του prototype, για το μοτίβο μεθόδου prototype σε instance (c.sum(...), arr.push(...) και κάθε αποστολή μεθόδου κλάσης). Η cache θεωρείται έγκυρη όταν ο δέκτης δεν έχει δική του θέση για το όνομα, η αποθηκευμένη αναφορά prototype ταυτίζεται με το getPrototype() του δέκτη και το PropertyMap::$version του prototype εξακολουθεί να αντιστοιχεί στην καταγεγραμμένη έκδοση.

Η ακύρωση της cache βασίζεται στο PropertyMap::$version. Οι δομικές μεταλλάξεις (set / delete) το αυξάνουν πάντα. Οι αντικαταστάσεις τιμών σε θέσεις δεδομένων το αυξάνουν μόνο όταν το PropertyMap::$isUsedAsProto είναι true — μια σημαία write-barrier που ενεργοποιείται την πρώτη φορά που το αντικείμενο εντοπίζεται ως [[Prototype]] ενός άλλου αντικειμένου. Τα αντικείμενα instance που δεν χρησιμοποιούνται ποτέ ως prototype δεν πληρώνουν το κόστος της υποδομής IC σε κάθε obj.x = v.

Οι προσπελάσεις getter δεν αποθηκεύονται ποτέ στην cache (πρέπει να εκτελούνται ξανά σε κάθε ανάγνωση).

Το PropertyMap διατηρεί δεύτερο μετρητή, το $mutationVersion, που αυξάνεται πάντα σε κάθε εγγραφή (χωρίς write-barrier). Η cache JSON.stringify δεσμεύει αυτόν τον μετρητή σε κάθε εμφωλευμένο αντικείμενο/πίνακα που επισκέπτεται, ώστε μια μετάλλαξη μόνο στο instance, όπως το o.a = 99, να ακυρώνει σωστά τη συμβολοσειρά της cache, ακόμη κι όταν το $version της IC θα παρέλειπε την αύξηση.

Cache JSON.stringify

Το JsObject διατηρεί τα $jsonCacheString + $jsonCacheVersions (μια λίστα από ζεύγη [obj, mutationVersion] για κάθε εμφωλευμένο αντικείμενο που επισκέφτηκε η απλή διαδρομή stringify). Στην επόμενη κλήση JSON.stringify(o) χωρίς replacer / space, η απλή γρήγορη διαδρομή επαληθεύει ότι καμία καταγεγραμμένη έκδοση δεν έχει αλλάξει και επιστρέφει τη συμβολοσειρά της cache χωρίς νέα διάσχιση ή κωδικοποίηση. Δημοφιλή μοτίβα βαθιάς κλωνοποίησης, όπως το JSON.parse(JSON.stringify(o)), επιταχύνονται περίπου κατά 2× στο σκέλος stringify.

Η cache απορρίπτεται σιωπηρά αν αποτύχει ο έλεγχος (διαφέρει οποιαδήποτε καταγεγραμμένη έκδοση) και συμπληρώνεται ξανά στην επόμενη διάσχιση. Αντικείμενα με toJSON, getters, accessors, symbols ή θέσεις [[PrimitiveValue]] περνούν στην υλοποίηση σειριοποίησης της προδιαγραφής και δεν γεμίζουν ποτέ την cache — έτσι η διαδρομή cache βλέπει μόνο δομικά ομοιόμορφα δεδομένα.

Γρήγορες διαδρομές ενσωματωμένων

Τα μεγαλύτερα κέρδη επιδόσεων προέκυψαν από την προσθήκη ειδικών ενσωματωμένων λειτουργιών στη VM:

  • Atomics.load / store / compareExchange με συνεργατικό προγραμματισμό loadSpinHook / storeNotifyHook — ανακτήθηκαν 4 δοκιμές βρόχου αναμονής Atomics.
  • Date.prototype.getTimezoneOffset με cache DST απευθείας σε hashmap — 45 % ταχύτερο στις δοκιμές καταπόνησης DST του SpiderMonkey.
  • String.fromCharCode με inline γρήγορη διαδρομή ASCII — εξοικονομούνται ~1 M αποστολές ανά τυπική σάρωση.
  • decodeURI / decodeURIComponent με υπερταχεία διαδρομή %XX%XX%XX%XX 12 χαρακτήρων που χρησιμοποιεί μαζική αποκωδικοποίηση hex2bin.
  • Object.keys / Object.values / Object.entries με άμεση πρόσβαση στον χάρτη ιδιοτήτων.
  • Array.prototype.map / filter / forEach / reduce με εγγενή επανάληψη στον υποκείμενο πίνακα PHP.

Κάθε γρήγορη διαδρομή ελέγχεται μέσω «ελέγχου σχήματος» — αν ο δέκτης δεν έχει το αναμενόμενο σχήμα (e.g. μια προβολή TypedArray που δεν βασίζεται σε απλό PHP string), η VM περνά στην αργή διαδρομή που συμμορφώνεται με την προδιαγραφή.

Τι δεν κάνει αυτό

Η VM bytecode δεν είναι JIT με την έννοια του V8. Δεν παράγει εγγενή κώδικα μηχανής ανά συνάρτηση και δεν κάνει εικασίες βάσει παρατηρούμενων τύπων, πέρα από τον έλεγχο μονομορφικής ταυτότητας prototype της IC. Η ενσωμάτωση στα όρια συναρτήσεων προσεγγίζεται με την προσαρμοσμένη στοίβα κλήσεων (χωρίς επιπλέον πλαίσιο PHP ανά κλήση JS), αντί με μετατροπή του σώματος της καλούμενης συνάρτησης σε bytecode του καλούντος.

Ο transpiler JsToPhp είναι το πιο κοντινό πράγμα σε JIT που διαθέτει η μηχανή: παράγει πηγαίο κώδικα PHP για σώματα συχνών συναρτήσεων, τον οποίο το tracing JIT της έκδοσης 8.5 του PHP μεταγλωττίζει έπειτα σε εγγενή κώδικα. Το JsToPhp περνά από το eval(), οπότε επιβαρύνεται με εφάπαξ κόστος μεταγλώττισης και το tracing JIT πρέπει να εντοπίσει τη συνάρτηση σαν να ήταν κώδικας χρήστη. Η παράλειψη του βήματος eval() με απευθείας έκδοση opcodes opcache θα μείωνε περισσότερο τη διαφορά — δείτε Συγκριτικές μετρήσεις § Οδικός χάρτης.

Συγκριτικές μετρήσεις

Το bench/microbench.js εκτελεί δώδεκα και κάτι μικρές συγκριτικές δοκιμές (loop-arith, loop-fib, fn-recurse, fn-deep-recurse, obj-create, obj-prop, proto-method, gen-iterate, arr-push, arr-map, str-concat, str-split-join, json-roundtrip, closure, destructure). Διάμεσος χρόνος πραγματικού χρόνου:

Κέλυφος
php bench/run.php

Οι τρέχουσες μετρήσεις καταγράφονται στο BENCH.md μετά από κάθε εκτέλεση ροής εργασίας bench. Οι ροές εργασίας bench και του πίνακα test262 εκκινούνται χειροκίνητα.